---
name: knot-transaction-link
description: >
  Build a Knot transaction data integration: link merchant accounts via the SDK (or reuse
  accounts already linked through CardSwitcher or another product), sync SKU-level transactions
  with cursor pagination, handle updates and disconnects, and store them. Use when:
  (1) "integrate transaction data from Knot", (2) "implement transaction link",
  (3) "link merchant accounts and sync transactions", (4) "set up transaction data from scratch",
  (5) "create a session and link a merchant account", (6) "build the full transaction flow",
  (7) "prototype with transaction data" or "get sample transactions", (8) "generate test
  transaction data" for a UI or demo, (9) "sync transaction data" or "fetch/pull transactions
  from Knot", (10) "store transactions from the sync endpoint", (11) cursor-based transaction
  pagination.
metadata:
  author: Knot
  version: "1.0"
---

# Integrate Transaction Data

Implement the full flow to link merchant accounts and retrieve SKU-level transaction data from Knot's API, from session creation through transaction syncing.

For shared setup and other products, see the `knot` skill.

Already linking merchant accounts through another Knot product, such as CardSwitcher? Skip Steps 3–6. Everything else applies.

Only need sample data to prototype a UI or explore the data shape? Skip the SDK and webhooks and go to [Prototype with sample data](#prototype-with-sample-data).

## Before Starting: Load API Context

If the Knot docs MCP server (`https://docs.knotapi.com/mcp`) is connected, use its search tool to read these pages. Otherwise fetch them as Markdown (the `.md` URLs below), or use https://docs.knotapi.com/llms.txt as an index:

1. https://docs.knotapi.com/transaction-link/quickstart.md — Full quickstart guide
2. https://docs.knotapi.com/api-reference/products/transaction-link/sync.md — Sync Transactions endpoint
3. https://docs.knotapi.com/transaction-link/webhook-events/new-transactions-available.md and https://docs.knotapi.com/transaction-link/webhook-events/updated-transactions-available.md — Transaction webhook payloads
4. https://docs.knotapi.com/webhooks.md — Webhook verification and retry behavior

To add the MCP server, the user can run `npx add-mcp https://docs.knotapi.com/mcp --name knot-docs`. If neither the MCP server nor web fetch is available, the workflow below contains everything needed to build the integration.

## Workflow

### Step 1: API Authentication

All Knot API requests use Basic Auth: `Authorization: Basic base64(client_id:secret)`. Also send `Knot-Version: 2.0` on every request; if omitted, the API defaults to `2.0`, but sending it explicitly is recommended.

Base URLs: `https://development.knotapi.com` (development) and `https://production.knotapi.com` (production). Each environment has its own credentials.

Check the user's codebase for existing Knot credentials (environment variables, config files, `.env`). If credentials are already available, use them. If not, ask the user to provide their `client_id` and `secret` so you can base64-encode them and proceed. Point them to the Knot Dashboard at https://dashboard.knotapi.com/developers/keys where each environment (development and production) has its own credentials.

### Step 2: Webhook Endpoint

Ask the user: **Do you have an existing webhook URL configured in the Knot Dashboard at https://dashboard.knotapi.com/developers/webhooks?** If they have already integrated other Knot products (e.g. CardSwitcher, SubscriptionManager), they likely already have a webhook configured. If no webhook exists, build a new POST endpoint and provide the URL to the user so they can configure it in the Knot Dashboard at https://dashboard.knotapi.com/developers/webhooks for the appropriate environment.

The webhook endpoint must handle these events:

**`AUTHENTICATED`** — merchant account successfully linked:
```json
{
  "event": "AUTHENTICATED",
  "external_user_id": "user-123",
  "merchant": { "id": 19, "name": "DoorDash" },
  "session_id": "fb5aa994-ed1c-4c3e-b29a-b2a53222e584",
  "task_id": 25605,
  "timestamp": 1710864923198,
  "data": {
    "card_id": "",
    "metadata": {}
  }
}
```

**`NEW_TRANSACTIONS_AVAILABLE`** — new transactions ready to sync:
```json
{
  "event": "NEW_TRANSACTIONS_AVAILABLE",
  "external_user_id": "user-123",
  "merchant": { "id": 19, "name": "DoorDash" },
  "timestamp": 1710864923198
}
```

**`UPDATED_TRANSACTIONS_AVAILABLE`** — existing transactions changed (for example, `order_status` went from `SHIPPED` to `DELIVERED`, or `price.total` changed):
```json
{
  "event": "UPDATED_TRANSACTIONS_AVAILABLE",
  "external_user_id": "user-123",
  "merchant": { "id": 19, "name": "DoorDash" },
  "updated": [
    { "id": "txn-id-1" },
    { "id": "txn-id-2" }
  ],
  "timestamp": 1710864923198
}
```

**`ACCOUNT_LOGIN_REQUIRED`** — connection lost, user needs to re-authenticate:
```json
{
  "event": "ACCOUNT_LOGIN_REQUIRED",
  "external_user_id": "user-123",
  "merchant": { "id": 19, "name": "DoorDash" },
  "timestamp": 1710864923198
}
```

Route by `event` field:
- `AUTHENTICATED` -> mark merchant account as connected, store the connection
- `NEW_TRANSACTIONS_AVAILABLE` -> enqueue sync job for `(external_user_id, merchant.id)`
- `UPDATED_TRANSACTIONS_AVAILABLE` -> enqueue update job for each `updated[].id`
- `ACCOUNT_LOGIN_REQUIRED` -> mark merchant account as disconnected, prompt user to reconnect
- `MERCHANT_STATUS_UPDATE` -> update local merchant availability (see Step 3)

**Verify and acknowledge every webhook:**
- Verify the `Knot-Signature` header (HMAC-SHA256 with your secret) before trusting the payload. See the `knot-webhooks` skill and https://docs.knotapi.com/webhooks.md for the exact signing steps.
- Return 200 within 10 seconds and process the work asynchronously. On a non-200 response or timeout, Knot retries up to two times a few minutes apart, so handlers must be idempotent.

Steps 3–6 link merchant accounts. If accounts are already linked through another product, such as CardSwitcher, skip to Step 7.

### Step 3: List Available Merchants

Call `POST /merchant/list` with `{ "type": "transaction_link", "platform": "{platform}" }` in the request body to retrieve merchants available for linking. The `platform` parameter (`ios`, `android`, or `web`) should always be included so the response only contains merchants supported on that platform. When `platform` is provided, the response also includes a `min_sdk_version` field for each merchant. Only display merchants whose `min_sdk_version` is at or below the SDK version the app is running.

Call this endpoint once per platform at launch to populate the initial merchant list, then periodically (e.g. once per day) to stay current. For real-time updates, handle the `MERCHANT_STATUS_UPDATE` webhook, which fires when a merchant becomes available or unavailable. This event is emitted independently per product type and platform, so filter by both `type` and `platform` to determine which merchant is available for which product and platform.

Merchant IDs are static across all environments.

### Step 4: Create a Session

For each merchant the user wants to link, call `POST /session/create`:

```json
{
  "type": "transaction_link",
  "external_user_id": "user-123"
}
```

The response contains a `session` string. Pass this to the SDK in the next step.

### Step 5: Initialize the SDK

Initialize the Knot SDK with the `session` from the previous step and the `merchant_id` the user selected. Specifying a `merchant_id` is required for this product. See the `knot-sdk` skill for platform-specific SDK installation and initialization.

### Step 6: Handle Authentication

When the user successfully authenticates through the SDK, Knot sends the `AUTHENTICATED` webhook. Store the merchant account connection with status `connected`. You can also call `GET /accounts/get?external_user_id={id}` to retrieve all linked merchant accounts and their connection status.

### Step 7: Sync Transactions with Cursor Pagination

When the `NEW_TRANSACTIONS_AVAILABLE` webhook is received, call `POST /transactions/sync` in a loop using the `external_user_id` and `merchant.id` from the webhook payload. Pass the cursor from the previous response to get the next page. Continue until `next_cursor` is `null`.

```
cursor = load_stored_cursor(external_user_id, merchant_id)  // none on first sync

loop:
  body = { merchant_id, external_user_id, limit: 100 }
  if cursor: body.cursor = cursor  // omit cursor entirely on the first sync
  response = POST /transactions/sync body

  store_transactions(response.transactions)
  cursor = response.next_cursor
  // Important: persist cursor after each page to avoid re-scanning already-fetched transactions
  persist_cursor(external_user_id, merchant_id, cursor)

  break if cursor is null
```

**Cursor behavior:**
- `cursor` is an opaque string returned by the API that tells it where to resume
- On the first-ever sync, omit `cursor` from the request body to start from the beginning (the API types `cursor` as a string, so don't send `null`)
- On subsequent syncs (triggered by future `NEW_TRANSACTIONS_AVAILABLE` webhooks), send the last stored cursor to retrieve only new transactions since the previous sync
- `limit` range: 1-100. The API defaults to 5 if omitted; send 100 to minimize API calls
- When `next_cursor` is `null` in the response, all available transactions have been returned

**Persist the cursor:** Store the cursor keyed by `(external_user_id, merchant_id)` after each page. This ensures:
- If the sync job crashes mid-pagination, it resumes from the last completed page rather than re-fetching everything
- On the next `NEW_TRANSACTIONS_AVAILABLE` webhook, the sync starts where it left off and only retrieves new transactions

### Step 8: Handle Transaction Updates

When `UPDATED_TRANSACTIONS_AVAILABLE` is received, call `GET /transactions/{id}` for each transaction ID in the `updated` array. Each call returns a single transaction object. Upsert the updated transaction into storage keyed on its `id`, overwriting the previous version.

```
for each updated[].id:
  txn = GET /transactions/{id}
  upsert txn into storage (keyed on txn.id)
```

### Step 9: Handle Disconnections

When `ACCOUNT_LOGIN_REQUIRED` is received, the merchant account connection has been lost (e.g. the user changed their password). Mark the account as disconnected and display a reconnect prompt in your app. To reconnect, create a new session (Step 4) and re-initialize the SDK (Step 5).

### Step 10: Store Transactions

Store **all fields** from each transaction object. See the Transaction Object Schema below for the full schema with every field, its type, and whether it can be `null`. The implementation must handle nullable fields.

**Deduplication:** Use the transaction `id` as the dedup key via upsert (`INSERT ... ON CONFLICT (id) DO UPDATE` in PostgreSQL, or the `id` as the document key in a NoSQL store). The same transaction can arrive more than once: webhook retries can start overlapping sync jobs, a job that crashes after storing a page but before saving the cursor re-fetches that page, and transactions near a cursor boundary can occasionally appear on two pages.

**Schema recommendation:**
```sql
-- Adapt types/syntax to your database (e.g. JSON column type, timestamp handling)
CREATE TABLE knot_transactions (
  id TEXT PRIMARY KEY,                    -- Knot's transaction ID, dedup key
  user_id TEXT NOT NULL REFERENCES users(id),  -- FK to your users table
  merchant_id INTEGER NOT NULL,
  external_id TEXT,                       -- Merchant-provided order identifier (nullable)
  datetime TIMESTAMP NOT NULL,
  order_status TEXT NOT NULL,
  url TEXT,
  price_total TEXT NOT NULL,
  price_sub_total TEXT,
  price_currency TEXT,
  products JSON,                          -- Array of product objects; use JSON/JSONB or normalize
  payment_methods JSON,                   -- Array of payment method objects
  price_adjustments JSON,                 -- price.adjustments array
  shipping JSON,                          -- Shipping object (nullable)
  loyalty_membership JSON,                -- { tier } (nullable)
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now()
);

CREATE INDEX idx_knot_txn_user_merchant
  ON knot_transactions (user_id, merchant_id);

CREATE TABLE knot_sync_cursors (
  user_id TEXT NOT NULL REFERENCES users(id),
  merchant_id INTEGER NOT NULL,
  cursor TEXT,                            -- Last cursor from /transactions/sync
  updated_at TIMESTAMP DEFAULT now()
);

CREATE TABLE knot_merchant_accounts (
  user_id TEXT NOT NULL REFERENCES users(id),
  merchant_id INTEGER NOT NULL,
  connection_status TEXT NOT NULL DEFAULT 'connected',  -- connected or disconnected
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now()
);
```

The upsert (used for both new syncs and updates):
```sql
INSERT INTO knot_transactions (id, user_id, merchant_id, external_id, datetime, order_status, url, price_total, price_sub_total, price_currency, products, payment_methods, price_adjustments, shipping, loyalty_membership, updated_at)
-- Named bind parameters; use your driver's placeholder syntax
VALUES (:id, :user_id, :merchant_id, :external_id, :datetime, :order_status, :url, :price_total, :price_sub_total, :price_currency, :products, :payment_methods, :price_adjustments, :shipping, :loyalty_membership, now())
ON CONFLICT (id) DO UPDATE SET
  external_id = EXCLUDED.external_id,
  datetime = EXCLUDED.datetime,
  order_status = EXCLUDED.order_status,
  url = EXCLUDED.url,
  price_total = EXCLUDED.price_total,
  price_sub_total = EXCLUDED.price_sub_total,
  price_currency = EXCLUDED.price_currency,
  products = EXCLUDED.products,
  payment_methods = EXCLUDED.payment_methods,
  price_adjustments = EXCLUDED.price_adjustments,
  shipping = EXCLUDED.shipping,
  loyalty_membership = EXCLUDED.loyalty_membership,
  updated_at = now();
```

### Step 11: Testing

In the development environment, use `POST /development/accounts/link` (base URL: `https://development.knotapi.com`) to link a merchant account and generate sample transactions without the client-side SDK. This triggers the same webhook flow as a real user.

**Generate new transactions:**
```
POST https://development.knotapi.com/development/accounts/link
{
  "external_user_id": "test-user-1",
  "merchant_id": 19,
  "transactions": {
    "new": true,
    "updated": false
  }
}
```

This will:
1. Link the merchant account and emit an `AUTHENTICATED` webhook
2. Generate 205 sample transactions within a few seconds
3. Emit a `NEW_TRANSACTIONS_AVAILABLE` webhook, triggering the sync flow

**Generate new + updated transactions:**
```
POST https://development.knotapi.com/development/accounts/link
{
  "external_user_id": "test-user-1",
  "merchant_id": 19,
  "transactions": {
    "new": true,
    "updated": true
  }
}
```

Same as above, but also updates a few of the newly-generated transactions and emits an `UPDATED_TRANSACTIONS_AVAILABLE` webhook with their IDs shortly after. `new: true` is required to use `updated: true`.

**With the SDK:** create a `transaction_link` session, open the SDK on a merchant, and log in with one of these test accounts. See [Transaction Link testing](https://docs.knotapi.com/transaction-link/testing.md).

| Scenario | Username | Password |
| --- | --- | --- |
| New transactions | `user_good_transactions` | `pass_good` |
| New and updated transactions | `user_good_transactions` | `pass_good_updates` |

`AUTHENTICATED` arrives on login, and `NEW_TRANSACTIONS_AVAILABLE` follows within a few seconds. `pass_good_updates` also sends `UPDATED_TRANSACTIONS_AVAILABLE`. The general `user_good` / `pass_good` login links the account but generates no transactions.

**Simulate a disconnection:**
```
POST https://development.knotapi.com/development/accounts/disconnect
{
  "external_user_id": "test-user-1",
  "merchant_id": 19
}
```

This sets the merchant account's `connection.status` to `disconnected` and emits an `ACCOUNT_LOGIN_REQUIRED` webhook, so you can test the reconnect flow (Step 9).

**Tips:**
- Use `merchant_id: 19` (DoorDash) for testing
- If testing multiple times consecutively, use a different `external_user_id` each time
- Ensure a webhook URL is configured in the Knot Dashboard at https://dashboard.knotapi.com/developers/webhooks for the development environment before calling this endpoint
- Auth: same Basic Auth and `Knot-Version` header as all other API calls, using your development environment credentials
- In production, use `https://production.knotapi.com` with production credentials; the `/development/*` endpoints exist only in development

## Prototype with sample data

Use this path to get realistic transaction data fast, with no SDK, webhooks, or production setup. It only runs against `https://development.knotapi.com`. Never use production credentials here.

1. **Get development credentials.** Look for `KNOT_DEV_API_KEY` in a `.env` file first. If it isn't there, have the user copy the Client ID and Secret for the **Development** environment from [Dashboard → Developers → Keys](https://dashboard.knotapi.com/developers/keys). Send `Authorization: Basic base64(client_id:secret)` and `Knot-Version: 2.0`. Save the encoded value as `KNOT_DEV_API_KEY` in `.env` and make sure `.gitignore` covers `.env`.
2. **Link merchants and generate data.** Ask which merchants to use, then call Link Account once per merchant with the same `external_user_id`:

   ```
   POST https://development.knotapi.com/development/accounts/link
   { "external_user_id": "prototype-user-1", "merchant_id": 19, "transactions": { "new": true } }
   ```

   | Merchant | ID | | Merchant | ID |
   | --- | --- | --- | --- | --- |
   | Walmart | 45 | | Target | 12 |
   | Amazon | 44 | | Costco | 165 |
   | DoorDash | 19 | | Gopuff | 41 |
   | Uber Eats | 36 | | Shop Pay | 2125 |
   | Instacart | 40 | | | |

   Each merchant gets about 205 sample transactions. Use a new `external_user_id` (`prototype-user-2`, ...) each time you rerun this, so earlier data doesn't mix in.
3. **Pull the transactions.** Call `POST /transactions/sync` for each merchant with `limit: 100`, omit `cursor` on the first call, and follow `next_cursor` until it is `null` (about 3 pages per merchant). If the first call returns no transactions, generation hasn't finished yet; wait a couple of seconds and retry. The `merchant` object is on the response wrapper, not on each transaction.
4. **Use the data.** Build with it in the current session, or save it to a Markdown file (for example `knot-transactions-prototype.md`) with a short summary per merchant (count, date range, order statuses, total spend) followed by each transaction's date, status, total, products, and payment method.

When the prototype turns into a real integration, follow the Workflow above.

## Transaction Object Schema

Store all fields from each transaction. Fields marked nullable may be `null`; the implementation must handle this.

Canonical source: https://docs.knotapi.com/api-reference/products/transaction-link/transaction-object.md (generated from the API spec). If it differs from this copy, the docs page wins.

### Top-level fields

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `id` | string (UUID) | No | Unique transaction identifier. **Dedup key.** |
| `external_id` | string | Yes | Merchant-provided order identifier |
| `datetime` | string (ISO 8601) | No | Transaction timestamp in UTC. Knot does not guarantee the order in which transactions are returned. |
| `order_status` | enum | No | Current order lifecycle state |
| `url` | string | Yes | Direct link to order in merchant account |
| `price` | object | No | Price breakdown (see below) |
| `products` | array | No | SKU-level items (see below) |
| `payment_methods` | array | No | Payment methods used (see below) |
| `shipping` | object | Yes | Recipient name and delivery address (see below). `null` when no shipping data is available. |
| `loyalty_membership` | object | Yes | `{ tier }` (nullable string). Loyalty membership for the merchant account, not necessarily related to this transaction. `null` when the account has no loyalty membership. |

**Note:** The `merchant` object (`{ id, name }`) is returned in the `/transactions/sync` response wrapper, not inside each transaction object.

**order_status enum:** `ORDERED`, `BILLED`, `SHIPPED`, `DELIVERED`, `RETURNED`, `REFUNDED`, `CANCELLED`, `FAILED`, `COMPLETED`, `PICKED_UP`, `UNRECOGNIZED`

### price

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `sub_total` | string | Yes | Subtotal of the transaction |
| `adjustments` | array | No | Price adjustments (tax, fees, discounts, etc.) |
| `total` | string | No | Total of the transaction |
| `currency` | string | Yes | ISO 4217 currency code (e.g. "USD") |

Each `adjustments[]` item: `type` (enum, not null: `DISCOUNT`, `TAX`, `TIP`, `FEE`, `REFUND`, `UNRECOGNIZED`), `label` (nullable string from the merchant), `amount` (string, not null; positive increases the total, negative decreases it).

### products[]

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `external_id` | string | Yes | Merchant-provided product identifier |
| `name` | string | No | Product name |
| `description` | string | Yes | Additional product details |
| `url` | string | Yes | Link to product page on merchant site |
| `image_url` | string | Yes | Link to product image |
| `quantity` | number | Yes | Number of units purchased (a number, not guaranteed to be an integer) |
| `price` | object | Yes | `{ sub_total, total, unit_price }`, all nullable strings |
| `seller` | object | Yes | `{ name, url }`, both nullable strings. Seller of the product, often different from the merchant on marketplaces. Nullable, but never an empty object. |
| `eligibility` | array of strings | No | Special spending categories. Currently `FSA/HSA`. |

The spec also lists `category` on products, marked coming soon. Tolerate them being absent, and tolerate new fields and enum values in general.

### payment_methods[]

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `external_id` | string | Yes | Merchant-provided payment method identifier |
| `type` | enum | No | Payment method category |
| `brand` | string | Yes | Card network or store card name (includes `EBTSNAP`) |
| `last_four` | string | Yes | Last four digits, if a payment card |
| `name` | string | Yes | User-provided label (e.g. "Work Card") |
| `transaction_amount` | string | Yes | Amount charged to this payment method |

**type enum:** `CARD`, `APPLE_PAY`, `GOOGLE_PAY`, `AMAZON_PAY`, `PAYPAL`, `CASH_APP`, `VENMO`, `AFFIRM`, `KLARNA`, `ONEPAY`, `GIFT_CARD`, `CASH`, `BANK_ACCOUNT`, `LOYALTY_POINTS`, `CRYPTO`, `UNRECOGNIZED`

### shipping

`shipping` (nullable) contains one field, `location` (nullable):

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `location.first_name` | string | Yes | Recipient's first name |
| `location.last_name` | string | Yes | Recipient's last name |
| `location.address` | object | Yes | Delivery address (see below) |

`location.address` fields: `line1`, `line2`, `city`, `region` (ISO 3166-2 code, e.g. `"CA"`), `postal_code` are all nullable strings; `country` (ISO 3166-1 alpha-2, e.g. `"US"`) is a non-null string.
