---
title: "Receiving events"
description: "Register an endpoint, verify the signature on every delivery, and respond in time."
kind: how-to
url: /docs/webhooks
canonical_url: "https://virtual.ktap.io/docs/webhooks"
---

> Part of the KTAP Cards API documentation.
> Index of every page: https://virtual.ktap.io/llms.txt
> Machine-readable schema: https://virtual.ktap.io/api/partner/v1/openapi.json

# Receiving events

Register an endpoint, verify the signature on every delivery, and respond in time.

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

```http
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...
```

```json
{
  "id": "4f1c9d2a-1f2e-4a55-9a5d-6b6b0a1d9c33",
  "type": "card.authorized",
  "created_at": "2026-08-19T14:22:41.518Z",
  "data": { }
}
```

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

**Verification**


```typescript
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);
}
```

```python
import hashlib
import hmac
import time

# Five minutes. Long enough for a legitimate retry, short enough to bound replay.
TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
    timestamp = headers.get("x-ktap-timestamp")
    signature = headers.get("x-ktap-signature")
    if not timestamp or not signature:
        return False

    # We do not enforce a freshness window, so you should.
    try:
        age = abs(time.time() - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature.lower())
```

```go
package ktap

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// Five minutes. Long enough for a legitimate retry, short enough to bound replay.
const toleranceSeconds = 300

func Verify(rawBody []byte, header http.Header, secret string) bool {
	timestamp := header.Get("X-KTAP-Timestamp")
	signature := header.Get("X-KTAP-Signature")
	if timestamp == "" || signature == "" {
		return false
	}

	// We do not enforce a freshness window, so you should.
	sent, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-sent)) > toleranceSeconds {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(expected), []byte(strings.ToLower(signature)))
}
```

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](https://virtual.ktap.io/docs/api/ledger.md#listCardTransactions) 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`.

| 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 `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 /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`

**Returns** Your current webhook setting, or null if you have never registered one, as `200`.

**Response 200** Your current webhook setting, or null if you have never registered one

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `webhook` | object, nullable | optional | Null until your first successful PUT /webhook |
|   `url` | string | optional | The normalised absolute https URL events are posted to |
|   `active` | boolean | optional | Whether the delivery loop will send to this URL. True on every row the API can create |
|   `updated_at` | string | optional | ISO 8601 UTC with milliseconds. Moves on every PUT, including one that keeps your existing secret |

```json
{
  "data": {
    "webhook": {
      "url": "https://api.yoursite.com/hooks/ktap",
      "active": true,
      "updated_at": "2026-08-12T10:37:58.160Z"
    }
  }
}
```

**Response 200** No webhook has ever been registered for your account

```json
{
  "data": {
    "webhook": null
  }
}
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 401 | `UNAUTHORIZED` | 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 |
| 403 | `FORBIDDEN` | The key is valid but does not carry users:read | Do not retry. Use a key that carries the scope |
| 405 | `BAD_REQUEST` | POST, PATCH or DELETE is used on /webhook | Do not retry. Use GET to read or PUT to set |
| 429 | `RATE_LIMITED` | The key exceeded its per-minute allowance | Wait the number of seconds in Retry-After, which is always 60, then resend |
| 500 | `UNAVAILABLE` | 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`.

### PUT /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**

| Header |  | Description |
| --- | --- | --- |
| `Content-Type` | optional | application/json. The body is parsed as JSON regardless of this header |

**Request body**

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `url` | string | required | 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. |
| `rotate` | boolean | optional | 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`. |

```json
{
  "url": "https://api.yoursite.com/hooks/ktap"
}
```

**Returns** A secret was minted: this is your first registration, the URL changed, or you sent rotate true. Store the secret now, because no endpoint returns it again, as `200`.

**Response 200** A secret was minted: this is your first registration, the URL changed, or you sent rotate true. Store the secret now, because no endpoint returns it again

| Field | Type |  | Description |
| --- | --- | --- | --- |
|   `url` | string | optional | The normalised URL that was stored |
| `secret` | string, nullable | optional | The signing secret, formatted as whsec_ followed by 32 base64url characters. Non-null only when a secret was minted on this call |
| `note` | string | optional | Human-readable guidance. The wording is not part of the contract, so branch on whether secret is null |

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

**Response 200** The normalised URL matched what was already stored and rotate was not true. The stored secret is untouched, active is set true and updated_at is refreshed

```json
{
  "data": {
    "webhook": {
      "url": "https://api.yoursite.com/hooks/ktap"
    },
    "secret": null,
    "note": "URL unchanged. Your existing secret still applies. Send {\"rotate\": true} to force a new one."
  }
}
```

**Response 400** The body is not a JSON object, or the URL failed validation. The same status and code are also returned when the save itself fails, because the handler only echoes messages matching its own validation allowlist

```json
{
  "error": "Webhook URL must use https",
  "code": "BAD_REQUEST"
}
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_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 |
| 400 | `BAD_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 |
| 401 | `UNAUTHORIZED` | 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 |
| 403 | `FORBIDDEN` | The key is valid but does not carry users:write | Do not retry. Use a key that carries the scope |
| 405 | `BAD_REQUEST` | POST, PATCH or DELETE is used on /webhook | Do not retry. Use PUT |
| 429 | `RATE_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.

### GET /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**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `type` | string | optional | Only events of this type, for example card.transaction |
| `since` | string | optional | Only events created at or after this ISO 8601 timestamp |
| `limit` | number | optional | Page size 1 to 100. Defaults to `50`. |
| `page` | number | optional | 1-based page number Defaults to `1`. |

**Returns** Event history, as `200`.

**Response 200** Event history

| Field | Type |  | Description |
| --- | --- | --- | --- |
| `events` | array | optional | One entry per event, newest first |
|   `id` | string | optional | The event id. The same id appears on the webhook delivery, so dedupe works across both |
|   `type` | string | optional | The event type |
|   `payload` | object | optional | Exactly the object the webhook delivery carries under data |
|   `delivered` | boolean | optional | Whether a delivery attempt has succeeded |
|   `delivered_at` | string, nullable | optional | When it succeeded, or null |
|   `attempts` | number | optional | Delivery attempts so far |
|   `created_at` | string | optional | When the event was generated, UTC |
| `count` | number | optional | Events in this page |
| `page` | number | optional | This page |
| `total` | number | optional | Events matching the filter |
| `total_pages` | number | optional | Page until page equals total_pages |

```json
{ "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 } }
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | `BAD_REQUEST` | limit above 100, or an unparseable since | Do not retry |

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

### POST /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**

| Parameter | Type |  | Description |
| --- | --- | --- | --- |
| `eventId` | string | optional | From GET /events. Events belonging to another account return 404, the same as ids that do not exist |

**Returns** Queued for delivery, as `200`.

**Response 200** Queued for delivery

```json
{ "data": { "event_id": "5f2c9a1e", "type": "card.transaction", "queued": true } }
```

**Errors**

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 404 | `NOT_FOUND` | Unknown event, or an event that is not yours | Do not retry |

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