Skip to main content
Build a rewards program for cardholders using Stripe Issuing. The integration is about 60 lines of code and a few rules. Stripe sends webhook events when card transactions settle and refund, your middleware transforms and forwards them, and Scrip handles the rest.

Architecture

Stripe webhooks can’t be pointed directly at Scrip. The payload formats, authentication, and data conventions are different, so you need a thin middleware layer between them: The middleware is small, about 60 lines of code. It 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 sideScrip side
A Stripe Issuing integration with active cardsA program created for your card rewards
A webhook endpoint (configured in Webhook setup)An asset linked to that program (e.g., POINTS or CASHBACK_USD)
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
See the 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:
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 field is a good place to store your internal user ID.

Webhook setup

In the Stripe Dashboard or via the API, create a webhook endpoint that points to your middleware URL and subscribes to issuing_transaction.created:
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.
We recommend calculating rewards on settled transactions only. 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 for details.

Middleware

You need to build a small service that sits between Stripe and Scrip. It receives Stripe webhook events, verifies the signature, transforms the payload into the format Scrip expects, and forwards it to POST /v1/events. The examples below are complete and ready to deploy.
Stripe fieldScrip fieldTransformation
data.object.ididempotency_keyUse directly (e.g., ipi_1Mz...)
data.object.cardholderexternal_idUse directly (e.g., ich_1Mz...)
data.object.createdevent_timestampConvert Unix timestamp to RFC 3339
data.object.typeevent_data.typeMap "capture""purchase", "refund""refund"
data.object.amountevent_data.amountabs(amount) / 100. Stripe uses cents, negative for captures.
data.object.currencyevent_data.currencyUppercase (e.g., "usd""USD")
data.object.merchant_data.category_codeevent_data.mccUse directly (e.g., "5812")
data.object.merchant_data.nameevent_data.merchant_nameUse directly
data.object.merchant_data.cityevent_data.merchant_cityUse directly
data.object.merchant_data.countryevent_data.merchant_countryUse directly
data.object.authorizationevent_data.authorization_idUse directly. Links refunds to the original purchase.
data.object.walletevent_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 includes purchase details, network data, and more. Anything you add to event_data is available in rule conditions as event.<field_name>.
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);
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.

Rules

With the middleware in place, Stripe transactions arrive as Scrip events with a consistent event_data shape. Here’s a complete rule set for a cashback card with category multipliers and refund handling. Earning rules Award cashback when event_data.type is "purchase":
{
  "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"
    }
  ]
}
{
  "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"
    }
  ]
}
{
  "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 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:
{
  "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"
    }
  ]
}
{
  "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"
    }
  ]
}
{
  "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.
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 for more on this behavior.
Excluding non-qualifying transactions Block cash advances, wire transfers, and gambling from earning rewards by adding MCC exclusions to your rules:
event.type == "purchase" && !(event.mcc in ["6010", "6011", "6012", "6051", "4829", "7995"])
MCCCategory
6010, 6011, 6012Cash advances / ATM withdrawals
6051Quasi-cash (money orders, wire transfers)
4829Wire transfers
7995Gambling
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

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. The Stripe Transaction object includes an authorization field that references the original authorization ID. This link is present on both captures and refunds.
Scenarioauthorization field
Normal captureSet to the authorization ID
Normal refundUsually set to the original authorization ID
Force capture (no prior auth)null
Unlinked refundnull
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:
event.type == "refund" && event.authorization_id != null
Stripe says linking refunds to authorizations is “an inexact science”. 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

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. 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:
// In your middleware's transform logic
let eventType;
if (txn.type === "capture") {
  eventType = "purchase";
} else if (txn.type === "refund" && txn.amount < 0) {
  // Refund reversal: treat as a purchase (re-award points)
  eventType = "purchase";
} 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:
{
  "name": "refund_with_original_rate",
  "order": 50,
  "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 50) when original_earn_rate is present, and the standard MCC-based refund rules handle cases where it isn’t. Disputes. Stripe Issuing 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.

Testing

Stripe Issuing supports test mode. Create test cardholders and simulate transactions:
# 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:
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:
# 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 for more on Scrip’s testing tools.

Example

A cardholder buys an $85 lunch at a restaurant (MCC 5812).
1

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

Merchant settles the transaction

The next day, the merchant captures the authorization. Stripe creates a Transaction object and fires issuing_transaction.created:
{
  "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
    }
  }
}
3

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 (-850085.00), the type maps from "capture" to "purchase", and the cardholder ID becomes the external_id:
{
  "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
  }
}
4

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

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

Next steps

This guide covers the core integration. To build on it:
  • Add spend thresholds, retroactive bonuses, and monthly resets with the Cashback Card example
  • Browse more rule recipes like sign-up bonuses, streaks, and tiered earn rates in Common Patterns
  • Learn how events flow through the processing pipeline in Event Processing
For Stripe-specific reference, see the Issuing Transactions API and transaction lifecycle docs.