> ## Documentation Index
> Fetch the complete documentation index at: https://docs.scrip.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Stripe Issuing

> Connect Stripe Issuing card transactions to your rewards program

Build a rewards program for cardholders using [Stripe Issuing](https://docs.stripe.com/issuing). Stripe sends webhook events when card transactions are authorized and settled, your middleware transforms and forwards them, and Scrip handles the rest.

## Choose your approach

Stripe Issuing supports two integration patterns depending on whether you need to show pending rewards before a transaction settles.

|                      | Settlement-only                                              | Auth + Settlement                                                                                                                                                                                            |
| -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **When to use**      | You only need to award rewards after the transaction settles | You want to show pending rewards immediately when the cardholder taps their card                                                                                                                             |
| **Stripe events**    | `issuing_transaction.created`                                | `issuing_authorization.created` + `issuing_authorization.updated` + `issuing_transaction.created`                                                                                                            |
| **Scrip asset mode** | `SIMPLE`                                                     | `LOT` (required for `reference_id` lot stamping)                                                                                                                                                             |
| **Complexity**       | \~60 lines of middleware, 6 rules                            | \~100 lines of middleware, 10 rules                                                                                                                                                                          |
| **Trade-off**        | Simpler. No pending state to manage.                         | Cardholders see rewards in real time. Voided authorizations are cancelled immediately. The system reconciles automatically if the settlement amount differs from the authorization (tips, partial captures). |

**Start with settlement-only** if you're unsure. You can add authorization holds later without changing your existing rules: the settlement rules continue to work as-is when there are no held lots to reconcile.

## Architecture

<Tabs>
  <Tab title="Settlement-only">
    ```mermaid theme={null}
    sequenceDiagram
        participant Stripe as Stripe Issuing
        participant MW as Your Server
        participant Scrip as Scrip

        Stripe->>MW: issuing_transaction.created
        MW->>MW: Verify Stripe signature
        MW-->>Stripe: 200 OK
        MW->>MW: Transform payload
        MW->>Scrip: POST /v1/events
        Scrip-->>MW: 202 Accepted
        Scrip->>Scrip: Evaluate rules
        Scrip->>Scrip: Credit rewards
    ```

    The middleware verifies the Stripe signature, extracts the fields Scrip needs, and forwards the event. No business logic lives here; all reward calculations happen in Scrip rules.

    **What you need:**

    | Stripe side                                                                       | Scrip side                                                                                 |
    | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
    | A [Stripe Issuing](https://docs.stripe.com/issuing) integration with active cards | A program created for your card rewards                                                    |
    | A webhook endpoint (configured in [Webhook setup](#webhook-setup))                | An asset linked to that program (e.g., `CASHBACK_USD`, `SIMPLE` mode)                      |
    |                                                                                   | Participants enrolled with `external_id` set to the Stripe cardholder ID (e.g., `ich_...`) |
    |                                                                                   | An API key for your middleware to call the Scrip API                                       |
  </Tab>

  <Tab title="Auth + Settlement">
    ```mermaid theme={null}
    sequenceDiagram
        participant Stripe as Stripe Issuing
        participant MW as Your Server
        participant Scrip as Scrip

        Stripe->>MW: issuing_authorization.created
        MW->>MW: Verify & transform
        MW->>Scrip: POST /v1/events (type: auth)
        Scrip->>Scrip: Hold provisional rewards

        Note over Stripe,Scrip: Merchant captures (hours to days later)

        Stripe->>MW: issuing_transaction.created
        MW->>MW: Verify & transform
        MW->>Scrip: POST /v1/events (type: settlement)
        Scrip->>Scrip: Reconcile held → available
    ```

    The middleware handles two Stripe event types. Authorization events mint provisional rewards into the `HELD` bucket. When the merchant captures, the settlement event triggers auto-reconciliation: held lots are consumed and the final amount is credited to `AVAILABLE`. If the amounts differ (tips, partial captures), the delta is handled automatically.

    **What you need:**

    | Stripe side                                                                       | Scrip side                                                                                        |
    | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
    | A [Stripe Issuing](https://docs.stripe.com/issuing) integration with active cards | A program created for your card rewards                                                           |
    | A webhook endpoint (configured in [Webhook setup](#webhook-setup))                | An asset linked to that program in **`LOT` mode** (e.g., `CASHBACK_USD`, `LOT` mode, `UNLIMITED`) |
    |                                                                                   | Participants enrolled with `external_id` set to the Stripe cardholder ID (e.g., `ich_...`)        |
    |                                                                                   | An API key for your middleware to call the Scrip API                                              |
  </Tab>
</Tabs>

See the [quickstart](/quickstart) if you need help with initial setup.

Your middleware identifies cardholders by passing the Stripe `cardholder` ID as the Scrip `external_id`. This means each Scrip participant's `external_id` must match their Stripe cardholder ID:

```bash theme={null}
curl -X POST https://api.scrip.dev/v1/participants \
  -H "Authorization: Bearer $SCRIP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "program_id": "{{PROGRAM_ID}}",
    "external_id": "ich_1MzFMzK8F4fqH0lBmFq8CjbU",
    "display_name": "Jane Doe"
  }'
```

If you'd rather use your own user IDs, have your middleware look up the mapping before forwarding to Scrip. The Stripe cardholder object's [`metadata`](https://docs.stripe.com/api/issuing/cardholders/object#issuing_cardholder_object-metadata) field is a good place to store your internal user ID.

## Webhook setup

<Tabs>
  <Tab title="Settlement-only">
    In the [Stripe Dashboard](https://dashboard.stripe.com/webhooks) or via the API, create a webhook endpoint that subscribes to `issuing_transaction.created`:

    ```bash theme={null}
    curl https://api.stripe.com/v1/webhook_endpoints \
      -u "$STRIPE_SECRET_KEY:" \
      -d url="https://your-server.com/webhooks/stripe" \
      -d "enabled_events[]"="issuing_transaction.created"
    ```

    Store the webhook signing secret (`whsec_...`). You'll use it to verify incoming requests.

    <Note>
      You only need `issuing_transaction.created`. This single event covers both captures (settlement) and refunds. The transaction's `type` field distinguishes them. Authorizations (`issuing_authorization.*`) are not settlement and can be reversed, expired, or never captured, which makes them unreliable for reward calculations. See [Stripe's transaction lifecycle](https://docs.stripe.com/issuing/purchases/transactions) for details.
    </Note>
  </Tab>

  <Tab title="Auth + Settlement">
    In the [Stripe Dashboard](https://dashboard.stripe.com/webhooks) or via the API, create a webhook endpoint that subscribes to authorization, transaction, and (optionally) authorization update events:

    ```bash theme={null}
    curl https://api.stripe.com/v1/webhook_endpoints \
      -u "$STRIPE_SECRET_KEY:" \
      -d url="https://your-server.com/webhooks/stripe" \
      -d "enabled_events[]"="issuing_authorization.created" \
      -d "enabled_events[]"="issuing_authorization.updated" \
      -d "enabled_events[]"="issuing_transaction.created"
    ```

    Store the webhook signing secret (`whsec_...`). You'll use it to verify incoming requests.

    | Event                           | When it fires                                              |
    | ------------------------------- | ---------------------------------------------------------- |
    | `issuing_authorization.created` | Card is authorized                                         |
    | `issuing_authorization.updated` | Authorization is reversed or closed without capture        |
    | `issuing_transaction.created`   | Merchant captures (settles) the payment or issues a refund |

    <Note>
      `issuing_authorization.updated` is optional. Without it, uncaptured authorizations expire based on the `expires_at` set in your auth rules (`720h` in the examples below). With it, you can void held rewards immediately when an authorization is reversed. See [Uncaptured authorizations](#uncaptured-authorizations) for details on both approaches.
    </Note>
  </Tab>
</Tabs>

## Middleware

You need a small service between Stripe and Scrip. It receives Stripe webhook events, verifies the signature, transforms the payload, and forwards it to `POST /v1/events`.

<Tabs>
  <Tab title="Settlement-only">
    | Stripe field                              | Scrip field                   | Transformation                                                 |
    | ----------------------------------------- | ----------------------------- | -------------------------------------------------------------- |
    | `data.object.id`                          | `idempotency_key`             | Use directly (e.g., `ipi_1Mz...`)                              |
    | `data.object.cardholder`                  | `external_id`                 | Use directly (e.g., `ich_1Mz...`)                              |
    | `data.object.created`                     | `event_timestamp`             | Convert Unix timestamp to RFC 3339                             |
    | `data.object.type`                        | `event_data.type`             | Map `"capture"` → `"purchase"`, `"refund"` → `"refund"`        |
    | `data.object.amount`                      | `event_data.amount`           | `abs(amount) / 100`. Stripe uses cents, negative for captures. |
    | `data.object.currency`                    | `event_data.currency`         | Uppercase (e.g., `"usd"` → `"USD"`)                            |
    | `data.object.merchant_data.category_code` | `event_data.mcc`              | Use directly (e.g., `"5812"`)                                  |
    | `data.object.merchant_data.name`          | `event_data.merchant_name`    | Use directly                                                   |
    | `data.object.merchant_data.city`          | `event_data.merchant_city`    | Use directly                                                   |
    | `data.object.merchant_data.country`       | `event_data.merchant_country` | Use directly                                                   |
    | `data.object.authorization`               | `event_data.authorization_id` | Use directly. Links refunds to the original purchase.          |
    | `data.object.wallet`                      | `event_data.wallet`           | `"apple_pay"`, `"google_pay"`, `"samsung_pay"`, or `null`      |

    The fields above are the ones used by the example rules in this guide. Your middleware can include any additional fields in `event_data` that are useful for your program. Stripe's [Transaction object](https://docs.stripe.com/api/issuing/transactions/object) includes purchase details, network data, and more. Anything you add to `event_data` is available in rule conditions as `event.<field_name>`.

    <CodeGroup>
      ```javascript Node.js theme={null}
      const express = require("express");
      const Stripe = require("stripe");

      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
      const app = express();

      const SCRIP_API_KEY = process.env.SCRIP_API_KEY;
      const SCRIP_PROGRAM_ID = process.env.SCRIP_PROGRAM_ID;
      const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;

      app.post(
        "/webhooks/stripe",
        express.raw({ type: "application/json" }),
        async (req, res) => {
          // 1. Verify the Stripe signature
          let event;
          try {
            event = stripe.webhooks.constructEvent(
              req.body,
              req.headers["stripe-signature"],
              STRIPE_WEBHOOK_SECRET
            );
          } catch (err) {
            console.error("Signature verification failed:", err.message);
            return res.status(400).send("Invalid signature");
          }

          // 2. Only handle issuing transaction events
          if (event.type !== "issuing_transaction.created") {
            return res.json({ received: true });
          }

          const txn = event.data.object;

          // 3. Transform and forward to Scrip
          const scripEvent = {
            program_id: SCRIP_PROGRAM_ID,
            external_id: txn.cardholder,
            idempotency_key: txn.id,
            event_timestamp: new Date(txn.created * 1000).toISOString(),
            event_data: {
              type: txn.type === "capture" ? "purchase" : "refund",
              amount: Math.abs(txn.amount) / 100,
              currency: txn.currency.toUpperCase(),
              mcc: txn.merchant_data.category_code,
              merchant_name: txn.merchant_data.name,
              merchant_city: txn.merchant_data.city,
              merchant_country: txn.merchant_data.country,
              authorization_id: txn.authorization,
              stripe_transaction_id: txn.id,
              wallet: txn.wallet,
            },
          };

          const response = await fetch("https://api.scrip.dev/v1/events", {
            method: "POST",
            headers: {
              Authorization: `Bearer ${SCRIP_API_KEY}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify(scripEvent),
          });

          if (!response.ok) {
            const body = await response.text();
            console.error("Scrip API error:", response.status, body);
            // Return 500 so Stripe retries this webhook
            return res.status(500).send("Failed to forward event");
          }

          res.json({ received: true });
        }
      );

      app.listen(3000);
      ```

      ```python Python theme={null}
      import os, stripe
      from flask import Flask, request, jsonify
      from datetime import datetime, timezone
      import requests

      app = Flask(__name__)

      stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
      STRIPE_WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
      SCRIP_API_KEY = os.environ["SCRIP_API_KEY"]
      SCRIP_PROGRAM_ID = os.environ["SCRIP_PROGRAM_ID"]

      @app.route("/webhooks/stripe", methods=["POST"])
      def stripe_webhook():
          # 1. Verify the Stripe signature
          try:
              event = stripe.Webhook.construct_event(
                  request.data,
                  request.headers["Stripe-Signature"],
                  STRIPE_WEBHOOK_SECRET,
              )
          except (ValueError, stripe.error.SignatureVerificationError):
              return "Invalid signature", 400

          # 2. Only handle issuing transaction events
          if event["type"] != "issuing_transaction.created":
              return jsonify(received=True)

          txn = event["data"]["object"]

          # 3. Transform and forward to Scrip
          scrip_event = {
              "program_id": SCRIP_PROGRAM_ID,
              "external_id": txn["cardholder"],
              "idempotency_key": txn["id"],
              "event_timestamp": datetime.fromtimestamp(
                  txn["created"], tz=timezone.utc
              ).isoformat(),
              "event_data": {
                  "type": "purchase" if txn["type"] == "capture" else "refund",
                  "amount": abs(txn["amount"]) / 100,
                  "currency": txn["currency"].upper(),
                  "mcc": txn["merchant_data"]["category_code"],
                  "merchant_name": txn["merchant_data"]["name"],
                  "merchant_city": txn["merchant_data"]["city"],
                  "merchant_country": txn["merchant_data"]["country"],
                  "authorization_id": txn.get("authorization"),
                  "stripe_transaction_id": txn["id"],
                  "wallet": txn.get("wallet"),
              },
          }

          resp = requests.post(
              "https://api.scrip.dev/v1/events",
              headers={
                  "Authorization": f"Bearer {SCRIP_API_KEY}",
                  "Content-Type": "application/json",
              },
              json=scrip_event,
          )

          if not resp.ok:
              print(f"Scrip API error: {resp.status_code} {resp.text}")
              return "Failed to forward event", 500

          return jsonify(received=True)
      ```
    </CodeGroup>
  </Tab>

  <Tab title="Auth + Settlement">
    The middleware handles three Stripe event types. Authorization events carry the same merchant data as transactions (MCC, merchant name, etc.), so reward rates can be calculated at auth time. Authorization updates detect voids.

    **Authorization events** (`issuing_authorization.created`):

    | Stripe field                              | Scrip field                   | Transformation                                                                 |
    | ----------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
    | `data.object.id`                          | `event_data.authorization_id` | Use directly (e.g., `iauth_1Mz...`). Also used as part of the idempotency key. |
    | `data.object.cardholder`                  | `external_id`                 | Use directly (e.g., `ich_1Mz...`)                                              |
    | `data.object.created`                     | `event_timestamp`             | Convert Unix timestamp to RFC 3339                                             |
    | (hardcoded)                               | `event_data.type`             | Always `"auth"`                                                                |
    | `data.object.amount`                      | `event_data.amount`           | `amount / 100`. Stripe uses cents. Auth amounts are positive.                  |
    | `data.object.currency`                    | `event_data.currency`         | Uppercase (e.g., `"usd"` → `"USD"`)                                            |
    | `data.object.merchant_data.category_code` | `event_data.mcc`              | Use directly (e.g., `"5812"`)                                                  |
    | `data.object.merchant_data.name`          | `event_data.merchant_name`    | Use directly                                                                   |
    | `data.object.merchant_data.city`          | `event_data.merchant_city`    | Use directly                                                                   |
    | `data.object.merchant_data.country`       | `event_data.merchant_country` | Use directly                                                                   |

    **Transaction events** (`issuing_transaction.created`):

    | Stripe field                              | Scrip field                   | Transformation                                                 |
    | ----------------------------------------- | ----------------------------- | -------------------------------------------------------------- |
    | `data.object.id`                          | `idempotency_key`             | Use directly (e.g., `ipi_1Mz...`)                              |
    | `data.object.cardholder`                  | `external_id`                 | Use directly                                                   |
    | `data.object.created`                     | `event_timestamp`             | Convert Unix timestamp to RFC 3339                             |
    | `data.object.type`                        | `event_data.type`             | Map `"capture"` → `"settlement"`, `"refund"` → `"refund"`      |
    | `data.object.amount`                      | `event_data.amount`           | `abs(amount) / 100`. Stripe uses cents, negative for captures. |
    | `data.object.authorization`               | `event_data.authorization_id` | Use directly. Links settlement to the original auth.           |
    | `data.object.merchant_data.category_code` | `event_data.mcc`              | Use directly                                                   |
    | `data.object.merchant_data.name`          | `event_data.merchant_name`    | Use directly                                                   |
    | `data.object.wallet`                      | `event_data.wallet`           | `"apple_pay"`, `"google_pay"`, `"samsung_pay"`, or `null`      |

    Note that captures are mapped to `"settlement"` (not `"purchase"`) so your rules can distinguish between the authorization and settlement stages.

    **Authorization update events** (`issuing_authorization.updated`):

    | Stripe field             | Scrip field                   | Transformation                                                                |
    | ------------------------ | ----------------------------- | ----------------------------------------------------------------------------- |
    | `data.object.id`         | `event_data.authorization_id` | Use directly. Same ID as the original auth.                                   |
    | `data.object.cardholder` | `external_id`                 | Use directly                                                                  |
    | (computed)               | `event_data.type`             | `"void"` when `status` is `reversed` or `closed` without a linked transaction |
    | `data.object.amount`     | `event_data.amount`           | `amount / 100`                                                                |

    Only forward this event when the authorization is voided (reversed or closed without capture). Other status updates (e.g., amount changes on incremental auths) can be ignored.

    <CodeGroup>
      ```javascript Node.js theme={null}
      const express = require("express");
      const Stripe = require("stripe");

      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
      const app = express();

      const SCRIP_API_KEY = process.env.SCRIP_API_KEY;
      const SCRIP_PROGRAM_ID = process.env.SCRIP_PROGRAM_ID;
      const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;

      app.post(
        "/webhooks/stripe",
        express.raw({ type: "application/json" }),
        async (req, res) => {
          let event;
          try {
            event = stripe.webhooks.constructEvent(
              req.body,
              req.headers["stripe-signature"],
              STRIPE_WEBHOOK_SECRET
            );
          } catch (err) {
            console.error("Signature verification failed:", err.message);
            return res.status(400).send("Invalid signature");
          }

          let scripEvent;

          if (event.type === "issuing_authorization.created") {
            const auth = event.data.object;
            scripEvent = {
              program_id: SCRIP_PROGRAM_ID,
              external_id: auth.cardholder,
              idempotency_key: `auth_${auth.id}`,
              event_timestamp: new Date(auth.created * 1000).toISOString(),
              event_data: {
                type: "auth",
                amount: auth.amount / 100,
                currency: auth.currency.toUpperCase(),
                mcc: auth.merchant_data.category_code,
                merchant_name: auth.merchant_data.name,
                merchant_city: auth.merchant_data.city,
                merchant_country: auth.merchant_data.country,
                authorization_id: auth.id,
              },
            };
          } else if (event.type === "issuing_authorization.updated") {
            const auth = event.data.object;
            // Only forward when the authorization is voided
            if (auth.status !== "reversed" && auth.status !== "closed") {
              return res.json({ received: true });
            }
            scripEvent = {
              program_id: SCRIP_PROGRAM_ID,
              external_id: auth.cardholder,
              idempotency_key: `void_${auth.id}`,
              event_timestamp: new Date().toISOString(),
              event_data: {
                type: "void",
                amount: auth.amount / 100,
                authorization_id: auth.id,
              },
            };
          } else if (event.type === "issuing_transaction.created") {
            const txn = event.data.object;
            scripEvent = {
              program_id: SCRIP_PROGRAM_ID,
              external_id: txn.cardholder,
              idempotency_key: txn.id,
              event_timestamp: new Date(txn.created * 1000).toISOString(),
              event_data: {
                type: txn.type === "capture" ? "settlement" : "refund",
                amount: Math.abs(txn.amount) / 100,
                currency: txn.currency.toUpperCase(),
                mcc: txn.merchant_data.category_code,
                merchant_name: txn.merchant_data.name,
                merchant_city: txn.merchant_data.city,
                merchant_country: txn.merchant_data.country,
                authorization_id: txn.authorization,
                stripe_transaction_id: txn.id,
                wallet: txn.wallet,
              },
            };
          } else {
            return res.json({ received: true });
          }

          const response = await fetch("https://api.scrip.dev/v1/events", {
            method: "POST",
            headers: {
              Authorization: `Bearer ${SCRIP_API_KEY}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify(scripEvent),
          });

          if (!response.ok) {
            const body = await response.text();
            console.error("Scrip API error:", response.status, body);
            return res.status(500).send("Failed to forward event");
          }

          res.json({ received: true });
        }
      );

      app.listen(3000);
      ```

      ```python Python theme={null}
      import os, stripe
      from flask import Flask, request, jsonify
      from datetime import datetime, timezone
      import requests

      app = Flask(__name__)

      stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
      STRIPE_WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
      SCRIP_API_KEY = os.environ["SCRIP_API_KEY"]
      SCRIP_PROGRAM_ID = os.environ["SCRIP_PROGRAM_ID"]

      @app.route("/webhooks/stripe", methods=["POST"])
      def stripe_webhook():
          try:
              event = stripe.Webhook.construct_event(
                  request.data,
                  request.headers["Stripe-Signature"],
                  STRIPE_WEBHOOK_SECRET,
              )
          except (ValueError, stripe.error.SignatureVerificationError):
              return "Invalid signature", 400

          scrip_event = None

          if event["type"] == "issuing_authorization.created":
              auth = event["data"]["object"]
              scrip_event = {
                  "program_id": SCRIP_PROGRAM_ID,
                  "external_id": auth["cardholder"],
                  "idempotency_key": f"auth_{auth['id']}",
                  "event_timestamp": datetime.fromtimestamp(
                      auth["created"], tz=timezone.utc
                  ).isoformat(),
                  "event_data": {
                      "type": "auth",
                      "amount": auth["amount"] / 100,
                      "currency": auth["currency"].upper(),
                      "mcc": auth["merchant_data"]["category_code"],
                      "merchant_name": auth["merchant_data"]["name"],
                      "merchant_city": auth["merchant_data"]["city"],
                      "merchant_country": auth["merchant_data"]["country"],
                      "authorization_id": auth["id"],
                  },
              }
          elif event["type"] == "issuing_authorization.updated":
              auth = event["data"]["object"]
              # Only forward when the authorization is voided
              if auth["status"] not in ("reversed", "closed"):
                  return jsonify(received=True)
              scrip_event = {
                  "program_id": SCRIP_PROGRAM_ID,
                  "external_id": auth["cardholder"],
                  "idempotency_key": f"void_{auth['id']}",
                  "event_timestamp": datetime.now(tz=timezone.utc).isoformat(),
                  "event_data": {
                      "type": "void",
                      "amount": auth["amount"] / 100,
                      "authorization_id": auth["id"],
                  },
              }
          elif event["type"] == "issuing_transaction.created":
              txn = event["data"]["object"]
              scrip_event = {
                  "program_id": SCRIP_PROGRAM_ID,
                  "external_id": txn["cardholder"],
                  "idempotency_key": txn["id"],
                  "event_timestamp": datetime.fromtimestamp(
                      txn["created"], tz=timezone.utc
                  ).isoformat(),
                  "event_data": {
                      "type": "settlement" if txn["type"] == "capture" else "refund",
                      "amount": abs(txn["amount"]) / 100,
                      "currency": txn["currency"].upper(),
                      "mcc": txn["merchant_data"]["category_code"],
                      "merchant_name": txn["merchant_data"]["name"],
                      "merchant_city": txn["merchant_data"]["city"],
                      "merchant_country": txn["merchant_data"]["country"],
                      "authorization_id": txn.get("authorization"),
                      "stripe_transaction_id": txn["id"],
                      "wallet": txn.get("wallet"),
                  },
              }
          else:
              return jsonify(received=True)

          resp = requests.post(
              "https://api.scrip.dev/v1/events",
              headers={
                  "Authorization": f"Bearer {SCRIP_API_KEY}",
                  "Content-Type": "application/json",
              },
              json=scrip_event,
          )

          if not resp.ok:
              print(f"Scrip API error: {resp.status_code} {resp.text}")
              return "Failed to forward event", 500

          return jsonify(received=True)
      ```
    </CodeGroup>
  </Tab>
</Tabs>

<Warning>
  Return a 2xx to Stripe quickly. If forwarding to Scrip fails, return 500 so Stripe retries the delivery. Stripe retries with exponential backoff for up to 3 days. See [Stripe's retry behavior](https://docs.stripe.com/webhooks#retry-logic).
</Warning>

## Rules

With the middleware in place, Stripe events arrive with a consistent `event_data` shape. Here's a complete rule set for a cashback card with category multipliers and refund handling.

<Tabs>
  <Tab title="Settlement-only">
    **Earning rules**

    Award cashback when `event_data.type` is `"purchase"`:

    ```json theme={null}
    {
      "name": "dining_5pct",
      "order": 100,
      "stop_after_match": true,
      "condition": "event.type == \"purchase\" && event.mcc in [\"5812\", \"5813\", \"5814\"]",
      "actions": [
        {
          "type": "CREDIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.05, 2) }}",
          "description": "Dining 5% cashback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "grocery_3pct",
      "order": 200,
      "stop_after_match": true,
      "condition": "event.type == \"purchase\" && event.mcc in [\"5411\", \"5422\"]",
      "actions": [
        {
          "type": "CREDIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.03, 2) }}",
          "description": "Grocery 3% cashback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "base_1pct",
      "order": 1000,
      "condition": "event.type == \"purchase\"",
      "actions": [
        {
          "type": "CREDIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.01, 2) }}",
          "description": "Base 1% cashback"
        }
      ]
    }
    ```

    Dining matches first at order 100 and `stop_after_match` prevents the base rule from stacking. See [Category multipliers](/examples/common-patterns#category-multipliers) for a deeper walkthrough.

    **Reversal rules**

    When a cardholder receives a refund, the cashback they earned on that purchase should be clawed back. Reversal rules mirror the earning rules with DEBIT instead of CREDIT, using the same multiplier per MCC so the debit matches what was originally awarded:

    ```json theme={null}
    {
      "name": "refund_dining",
      "order": 110,
      "stop_after_match": true,
      "condition": "event.type == \"refund\" && event.mcc in [\"5812\", \"5813\", \"5814\"]",
      "actions": [
        {
          "type": "DEBIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.05, 2) }}",
          "allow_negative": true,
          "description": "Refund: dining 5% clawback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "refund_grocery",
      "order": 210,
      "stop_after_match": true,
      "condition": "event.type == \"refund\" && event.mcc in [\"5411\", \"5422\"]",
      "actions": [
        {
          "type": "DEBIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.03, 2) }}",
          "allow_negative": true,
          "description": "Refund: grocery 3% clawback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "refund_base",
      "order": 1010,
      "condition": "event.type == \"refund\"",
      "actions": [
        {
          "type": "DEBIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.01, 2) }}",
          "allow_negative": true,
          "description": "Refund: base 1% clawback"
        }
      ]
    }
    ```

    The reversal amount is calculated from the refund amount and MCC, using the same multiplier as the earning rule. Stripe includes the MCC on both captures and refunds, so the rates always match.

    <Note>
      The refund rules above use `"allow_negative": true` so the debit succeeds even if the cardholder has already spent their cashback. The balance goes negative and is offset by future earnings. Without `allow_negative`, a DEBIT fails when the available balance is insufficient, and the refund event would be marked `FAILED`. See [Negative Balances](/guides/balance-operations#negative-balances) for more on this behavior.
    </Note>
  </Tab>

  <Tab title="Auth + Settlement">
    **Authorization rules**

    When a card is authorized, credit provisional rewards into the `HELD` bucket. The `reference_id` stamps the held lots with the authorization ID so they can be matched at settlement. The `expires_at` ensures uncaptured authorizations are cleaned up automatically.

    ```json theme={null}
    {
      "name": "auth_dining_5pct",
      "order": 100,
      "stop_after_match": true,
      "condition": "event.type == \"auth\" && event.mcc in [\"5812\", \"5813\", \"5814\"]",
      "actions": [
        {
          "type": "CREDIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.05, 2) }}",
          "bucket": "HELD",
          "reference_id": "${{ event.authorization_id }}",
          "expires_at": "720h",
          "description": "Pending: dining 5% cashback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "auth_grocery_3pct",
      "order": 200,
      "stop_after_match": true,
      "condition": "event.type == \"auth\" && event.mcc in [\"5411\", \"5422\"]",
      "actions": [
        {
          "type": "CREDIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.03, 2) }}",
          "bucket": "HELD",
          "reference_id": "${{ event.authorization_id }}",
          "expires_at": "720h",
          "description": "Pending: grocery 3% cashback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "auth_base_1pct",
      "order": 1000,
      "condition": "event.type == \"auth\"",
      "actions": [
        {
          "type": "CREDIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.01, 2) }}",
          "bucket": "HELD",
          "reference_id": "${{ event.authorization_id }}",
          "expires_at": "720h",
          "description": "Pending: base 1% cashback"
        }
      ]
    }
    ```

    The cardholder's balance now shows pending rewards in the `held` field. These are visible but not spendable.

    **Settlement rules**

    When the merchant captures the transaction, credit to `AVAILABLE` with the same `reference_id`. The system automatically reconciles the held lots against the settlement amount. If the amounts differ (tip added, partial capture), the delta is handled without any extra logic. Order values must be unique across active rules in a program, so the settlement rules use 120/220/1020 alongside the auth rules at 100/200/1000.

    ```json theme={null}
    {
      "name": "settle_dining_5pct",
      "order": 120,
      "stop_after_match": true,
      "condition": "event.type == \"settlement\" && event.mcc in [\"5812\", \"5813\", \"5814\"]",
      "actions": [
        {
          "type": "CREDIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.05, 2) }}",
          "reference_id": "${{ event.authorization_id }}",
          "description": "Dining 5% cashback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "settle_grocery_3pct",
      "order": 220,
      "stop_after_match": true,
      "condition": "event.type == \"settlement\" && event.mcc in [\"5411\", \"5422\"]",
      "actions": [
        {
          "type": "CREDIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.03, 2) }}",
          "reference_id": "${{ event.authorization_id }}",
          "description": "Grocery 3% cashback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "settle_base_1pct",
      "order": 1020,
      "condition": "event.type == \"settlement\"",
      "actions": [
        {
          "type": "CREDIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.01, 2) }}",
          "reference_id": "${{ event.authorization_id }}",
          "description": "Base 1% cashback"
        }
      ]
    }
    ```

    If the `authorization_id` is `null` (force capture with no prior auth), the settlement rules still work: the system falls back to a standard credit since there are no held lots to reconcile. See [Balance Operations: Auth / Settlement Pattern](/guides/balance-operations#auth--settlement-pattern) for details on how auto-reconciliation works.

    **Reversal rules**

    Refund handling is the same as settlement-only. Refunds arrive after settlement, so there are no held lots involved:

    ```json theme={null}
    {
      "name": "refund_dining",
      "order": 110,
      "stop_after_match": true,
      "condition": "event.type == \"refund\" && event.mcc in [\"5812\", \"5813\", \"5814\"]",
      "actions": [
        {
          "type": "DEBIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.05, 2) }}",
          "allow_negative": true,
          "description": "Refund: dining 5% clawback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "refund_grocery",
      "order": 210,
      "stop_after_match": true,
      "condition": "event.type == \"refund\" && event.mcc in [\"5411\", \"5422\"]",
      "actions": [
        {
          "type": "DEBIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.03, 2) }}",
          "allow_negative": true,
          "description": "Refund: grocery 3% clawback"
        }
      ]
    }
    ```

    ```json theme={null}
    {
      "name": "refund_base",
      "order": 1010,
      "condition": "event.type == \"refund\"",
      "actions": [
        {
          "type": "DEBIT",
          "asset_id": "{{ASSET_ID}}",
          "amount": "${{ round(event.amount * 0.01, 2) }}",
          "allow_negative": true,
          "description": "Refund: base 1% clawback"
        }
      ]
    }
    ```

    <Note>
      The refund rules use `"allow_negative": true` so the debit succeeds even if the cardholder has already spent their cashback. See [Negative Balances](/guides/balance-operations#negative-balances).
    </Note>

    **Void rule**

    When an authorization is reversed before settlement, cancel the provisional rewards. A single rule handles all MCCs because `VOID_HOLD` voids the entire accrual by `reference_id`:

    ```json theme={null}
    {
      "name": "void_auth",
      "order": 50,
      "condition": "event.type == \"void\"",
      "actions": [
        {
          "type": "VOID_HOLD",
          "asset_id": "{{ASSET_ID}}",
          "reference_id": "${{ event.authorization_id }}"
        }
      ]
    }
    ```

    This returns the held value to the program wallet (prefunded) or system issuance (unlimited) and removes the pending balance from the cardholder's view. If the authorization was already settled or the held lots already expired, the void is a no-op.

    See [Balance Operations: Void Hold](/guides/balance-operations#void-hold) for details on how void hold works.
  </Tab>
</Tabs>

**Excluding non-qualifying transactions**

Block cash advances, wire transfers, and gambling from earning rewards by adding MCC exclusions to your rules:

```javascript theme={null}
event.type == "purchase" && !(event.mcc in ["6010", "6011", "6012", "6051", "4829", "7995"])
```

| MCC              | Category                                  |
| ---------------- | ----------------------------------------- |
| 6010, 6011, 6012 | Cash advances / ATM withdrawals           |
| 6051             | Quasi-cash (money orders, wire transfers) |
| 4829             | Wire transfers                            |
| 7995             | Gambling                                  |

Add these to every earning rule's condition, or create a `stop_after_match` rule at a low order number that matches excluded MCCs and takes no actions. This swallows the event before it reaches earning rules.

## Transaction lifecycle

<Tabs>
  <Tab title="Settlement-only">
    ```mermaid theme={null}
    flowchart TD
        A[Cardholder taps card] --> B["Authorization created
        issuing_authorization.created
        Status: PENDING"]
        B --> C["Merchant captures payment
        (typically within 24h; hotels up to 31 days)"]
        C --> D["Transaction created
        issuing_transaction.created
        type: capture"]
        D --> E["Send purchase event to Scrip"]
        D --> F["[Optional] Merchant issues refund"]
        F --> G["Transaction created
        issuing_transaction.created
        type: refund"]
        G --> H["Send refund event to Scrip"]
    ```

    Authorizations are not final. They can be reversed, expired, partially captured, or over-captured. Awarding points on authorization would require reversing them on every one of these outcomes. Waiting for the `capture` transaction avoids this complexity entirely.
  </Tab>

  <Tab title="Auth + Settlement">
    ```mermaid theme={null}
    flowchart TD
        A[Cardholder taps card] --> B["Authorization created
        issuing_authorization.created"]
        B --> C["Send auth event to Scrip
        → Provisional rewards held"]
        B --> D{Merchant action}
        D -->|Captures| E["Transaction created
        issuing_transaction.created
        type: capture"]
        E --> F["Send settlement event to Scrip
        → Held rewards reconciled to available"]
        E --> G["[Optional] Merchant issues refund"]
        G --> H["Transaction created
        issuing_transaction.created
        type: refund"]
        H --> I["Send refund event to Scrip
        → Cashback debited"]
        D -->|Reverses| J["Authorization updated
        issuing_authorization.updated
        status: reversed"]
        J --> K["Send void event to Scrip
        → Held rewards voided"]
        D -->|Never captures| L["Authorization expires
        → Held lots expire automatically"]
    ```

    The authorization and settlement events both flow through your middleware to Scrip. Between the two, the cardholder sees pending rewards in their `held` balance. When the merchant captures, the held rewards move to `available`. If the merchant reverses the authorization, a `VOID_HOLD` rule cancels the provisional rewards immediately. If the authorization is never captured and no reversal arrives, the held lots expire based on the `expires_at` set in the auth rule.
  </Tab>
</Tabs>

The Stripe [Transaction object](https://docs.stripe.com/api/issuing/transactions/object) includes an `authorization` field that references the original authorization ID. This link is present on both captures and refunds.

| Scenario                      | `authorization` field                        |
| ----------------------------- | -------------------------------------------- |
| Normal capture                | Set to the authorization ID                  |
| Normal refund                 | Usually set to the original authorization ID |
| Force capture (no prior auth) | `null`                                       |
| Unlinked refund               | `null`                                       |

Your middleware passes `authorization_id` through in `event_data`. You can reference it in rule conditions if needed. For example, to skip refund processing on force captures:

```javascript theme={null}
event.type == "refund" && event.authorization_id != null
```

<Note>
  Stripe says linking refunds to authorizations is ["an inexact science"](https://docs.stripe.com/issuing/purchases/transactions#refunds). Some refunds arrive with `authorization: null`. For category-based reward rates, this doesn't matter. The refund carries its own MCC and the correct debit rate can be calculated independently. For more complex scenarios (threshold-based rates, tiered earn rates), see [Edge cases](#edge-cases).
</Note>

## Edge cases

**Partial refunds.** Stripe refunds can be for less than the original capture amount. The refund transaction's `amount` reflects only the refunded portion, and your reversal rules calculate the debit from that amount. No special handling needed. A \$40 refund on a \$100 dining purchase debits \$2.00 (5% of \$40).

**Multi-capture transactions.** Airlines, hotels, and car rental companies can create multiple capture transactions against a single authorization. Each capture arrives as a separate `issuing_transaction.created` event with a unique `transaction.id`, so they map to separate Scrip events with distinct idempotency keys.

**Force captures.** Some merchants settle without a prior authorization (e.g., offline transactions). These arrive as `type: "capture"` with `authorization: null`. Award points normally. The transaction is still a valid settled purchase. For the auth + settlement approach, the settlement rules still work: when `authorization_id` is `null`, there are no held lots to reconcile, so the system performs a standard credit.

**Refund reversals.** Stripe can reverse a refund if it was issued in error. This appears as a transaction with `type: "refund"` and a **negative** `amount`. Your middleware should detect this case and map it to a `"purchase"` type instead:

```javascript theme={null}
// In your middleware's transform logic
let eventType;
if (txn.type === "capture") {
  eventType = "purchase"; // or "settlement" for auth + settlement
} else if (txn.type === "refund" && txn.amount < 0) {
  // Refund reversal: treat as a purchase (re-award points)
  eventType = "purchase"; // or "settlement" for auth + settlement
} else {
  eventType = "refund";
}
```

**Threshold-based earn rates and refunds.** If your program uses spend thresholds (e.g., 1% normally, 3% after \$2,500/month), a refund processed weeks later may not reverse at the same rate the original purchase earned. Two approaches: accept the approximation (the difference is usually small), or have your middleware include the original earn rate in the refund event as `event.original_earn_rate` and use a priority refund rule that references it:

```json theme={null}
{
  "name": "refund_with_original_rate",
  "order": 40,
  "stop_after_match": true,
  "condition": "event.type == \"refund\" && has(event.original_earn_rate)",
  "actions": [
    {
      "type": "DEBIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * event.original_earn_rate, 2) }}",
      "allow_negative": true
    }
  ]
}
```

This rule takes priority (order 40, below the `void_auth` rule's order 50) when `original_earn_rate` is present, and the standard MCC-based refund rules handle cases where it isn't.

**Disputes.** Stripe Issuing [disputes](https://docs.stripe.com/issuing/purchases/disputes) follow a separate lifecycle. If a cardholder disputes a transaction and wins, the funds return to the issuing balance but no `issuing_transaction.created` event fires. Instead, Stripe emits `issuing_dispute.*` events. If you need to reverse points on successful disputes, subscribe to `issuing_dispute.closed` in your Stripe webhook and forward it as a refund event when `dispute.status === "won"`. For many programs, dispute-based reversals aren't necessary. The volume is low and the complexity isn't worth it.

### Uncaptured authorizations

*Auth + settlement only.* An authorization that is never captured (reversed by the merchant or expired by Stripe) does not produce an `issuing_transaction.created` event. Two approaches handle this:

**Active void (recommended).** The middleware and rules in this guide already handle this case. When `authorization.status` changes to `reversed` or `closed`, the middleware forwards a void event and the `void_auth` rule cancels the provisional rewards via `VOID_HOLD`. This removes the held balance from the cardholder's view and returns value to the program wallet.

**Passive expiration.** If you choose not to subscribe to `issuing_authorization.updated`, the held lots created on auth have an `expires_at` (set to `720h` in the example rules). They expire and are forfeited to breakage automatically. This is simpler but leaves stale held balances visible until expiration.

## Testing

Stripe Issuing supports [test mode](https://docs.stripe.com/issuing/testing). Create test cardholders and simulate transactions:

```bash theme={null}
# Create a test cardholder
curl https://api.stripe.com/v1/issuing/cardholders \
  -u "$STRIPE_SECRET_KEY:" \
  -d name="Test User" \
  -d email="test@example.com" \
  -d "billing[address][line1]"="123 Main St" \
  -d "billing[address][city]"="San Francisco" \
  -d "billing[address][state]"="CA" \
  -d "billing[address][postal_code]"="94105" \
  -d "billing[address][country]"="US" \
  -d type=individual \
  -d status=active \
  -d "individual[first_name]"="Test" \
  -d "individual[last_name]"="User" \
  -d "individual[card_issuing][user_terms_acceptance][date]"="$(date +%s)" \
  -d "individual[card_issuing][user_terms_acceptance][ip]"="127.0.0.1"

# Create a test transaction (force capture)
curl https://api.stripe.com/v1/test_helpers/issuing/transactions/create_force_capture \
  -u "$STRIPE_SECRET_KEY:" \
  -d amount=8500 \
  -d currency=usd \
  -d card="$TEST_CARD_ID" \
  -d "merchant_data[category]"=eating_places_restaurants \
  -d "merchant_data[name]"="Corner Bistro" \
  -d "merchant_data[city]"="San Francisco" \
  -d "merchant_data[country]"="US"
```

This creates a real `issuing_transaction.created` webhook event in test mode, which flows through your middleware to Scrip.

You can also test rules independently of Stripe by sending events directly to Scrip:

```bash theme={null}
curl -X POST https://api.scrip.dev/v1/events \
  -H "Authorization: Bearer $SCRIP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "program_id": "{{PROGRAM_ID}}",
    "external_id": "ich_test_cardholder",
    "idempotency_key": "test_txn_001",
    "event_timestamp": "2026-03-01T10:30:00Z",
    "event_data": {
      "type": "purchase",
      "amount": 85.00,
      "currency": "USD",
      "mcc": "5812",
      "merchant_name": "Corner Bistro",
      "authorization_id": "iauth_test_001",
      "stripe_transaction_id": "ipi_test_001"
    }
  }'
```

Look up the participant by external ID, then check their balance:

```bash theme={null}
# Find the participant by external ID
curl "https://api.scrip.dev/v1/participants?external_id=ich_test_cardholder" \
  -H "Authorization: Bearer $SCRIP_API_KEY"

# Use the returned id to check balances
curl https://api.scrip.dev/v1/participants/{id}/balances \
  -H "Authorization: Bearer $SCRIP_API_KEY"
```

See [Testing](/guides/testing) for more on Scrip's testing tools.

## Example

<Tabs>
  <Tab title="Settlement-only">
    A cardholder buys an \$85 lunch at a restaurant (MCC 5812).

    <Steps>
      <Step title="Cardholder pays at a restaurant">
        The card is charged \$85. Stripe creates an authorization and holds the funds on the issuing balance. No event is sent to Scrip yet.
      </Step>

      <Step title="Merchant settles the transaction">
        The next day, the merchant captures the authorization. Stripe creates a Transaction object and fires `issuing_transaction.created`:

        ```json theme={null}
        {
          "id": "evt_1QH7...",
          "type": "issuing_transaction.created",
          "data": {
            "object": {
              "id": "ipi_1QH7...",
              "type": "capture",
              "amount": -8500,
              "currency": "usd",
              "cardholder": "ich_1MzF...",
              "authorization": "iauth_1MzF...",
              "created": 1709312400,
              "merchant_data": {
                "category_code": "5812",
                "name": "Corner Bistro",
                "city": "San Francisco",
                "country": "US"
              },
              "wallet": null
            }
          }
        }
        ```
      </Step>

      <Step title="Middleware transforms and forwards">
        Your server receives the webhook, verifies the Stripe signature, and maps the payload to a Scrip event. The amount converts from cents to dollars (`-8500` → `85.00`), the type maps from `"capture"` to `"purchase"`, and the cardholder ID becomes the `external_id`:

        ```json theme={null}
        {
          "program_id": "prg_a1b2c3...",
          "external_id": "ich_1MzF...",
          "idempotency_key": "ipi_1QH7...",
          "event_timestamp": "2026-03-01T15:00:00Z",
          "event_data": {
            "type": "purchase",
            "amount": 85.00,
            "currency": "USD",
            "mcc": "5812",
            "merchant_name": "Corner Bistro",
            "merchant_city": "San Francisco",
            "merchant_country": "US",
            "authorization_id": "iauth_1MzF...",
            "stripe_transaction_id": "ipi_1QH7...",
            "wallet": null
          }
        }
        ```
      </Step>

      <Step title="Scrip processes the event">
        `dining_5pct` rule matches (`event.type == "purchase"` and MCC 5812). Credits \$4.25 (5% of \$85). `stop_after_match` prevents the base rule from also firing. Event status transitions to `COMPLETED`.
      </Step>

      <Step title="Two weeks later, cardholder gets a partial refund">
        \$40 refunded. Stripe fires another `issuing_transaction.created` with `type: "refund"`. Middleware forwards it. `refund_dining` rule matches and debits \$2.00 (5% of \$40).
      </Step>
    </Steps>
  </Tab>

  <Tab title="Auth + Settlement">
    A cardholder buys an \$85 lunch at a restaurant (MCC 5812). The bill includes a \$15 tip added after authorization.

    <Steps>
      <Step title="Cardholder pays at a restaurant">
        The card is authorized for \$85. Stripe fires `issuing_authorization.created`. Your middleware transforms and forwards it as an auth event:

        ```json theme={null}
        {
          "program_id": "prg_a1b2c3...",
          "external_id": "ich_1MzF...",
          "idempotency_key": "auth_iauth_1MzF...",
          "event_timestamp": "2026-03-01T14:00:00Z",
          "event_data": {
            "type": "auth",
            "amount": 85.00,
            "currency": "USD",
            "mcc": "5812",
            "merchant_name": "Corner Bistro",
            "authorization_id": "iauth_1MzF..."
          }
        }
        ```

        `auth_dining_5pct` matches. Credits \$4.25 (5% of \$85) to `HELD` with `reference_id: "iauth_1MzF..."`. The cardholder sees \$4.25 as pending rewards.
      </Step>

      <Step title="Merchant settles with a tip">
        The next day, the merchant captures \$100 (\$85 + \$15 tip). Stripe fires `issuing_transaction.created`. Your middleware forwards it as a settlement event:

        ```json theme={null}
        {
          "program_id": "prg_a1b2c3...",
          "external_id": "ich_1MzF...",
          "idempotency_key": "ipi_1QH7...",
          "event_timestamp": "2026-03-01T15:00:00Z",
          "event_data": {
            "type": "settlement",
            "amount": 100.00,
            "currency": "USD",
            "mcc": "5812",
            "merchant_name": "Corner Bistro",
            "authorization_id": "iauth_1MzF...",
            "stripe_transaction_id": "ipi_1QH7..."
          }
        }
        ```

        `settle_dining_5pct` matches. Credits \$5.00 (5% of \$100) to `AVAILABLE` with `reference_id: "iauth_1MzF..."`. The system auto-reconciles: the \$4.25 in held lots is consumed, and \$5.00 is credited to available. The \$0.75 delta is sourced from the program wallet (or system issuance for unlimited assets).
      </Step>

      <Step title="Cardholder's balance after settlement">
        ```json theme={null}
        {"available": "5.00", "held": "0.00", "deferred": "0.00"}
        ```

        The pending rewards are gone, replaced by the final settled amount.
      </Step>

      <Step title="Two weeks later, cardholder gets a partial refund">
        \$40 refunded (the meal, not the tip). Stripe fires `issuing_transaction.created` with `type: "refund"`. Middleware forwards it. `refund_dining` matches and debits \$2.00 (5% of \$40).
      </Step>
    </Steps>
  </Tab>
</Tabs>

## Next steps

This guide covers the core integration. To build on it:

* Add spend thresholds, retroactive bonuses, and monthly resets with the [Cashback Card](/examples/cashback-card) example
* Browse more rule recipes like sign-up bonuses, streaks, and tiered earn rates in [Common Patterns](/examples/common-patterns)
* Learn how events flow through the processing pipeline in [Event Processing](/guides/event-processing)
* Read about the [auth / settlement pattern](/guides/balance-operations#auth--settlement-pattern) for a deeper look at how auto-reconciliation works under the hood

For Stripe-specific reference, see the [Issuing Transactions API](https://docs.stripe.com/api/issuing/transactions) and [transaction lifecycle docs](https://docs.stripe.com/issuing/purchases/transactions).
