Skill: zmtucker/drivethru-stripe (version 0.2.0)

--- name: drivethru-stripe description: Look up products in the Stripe catalog and create a Stripe Checkout Session that returns a hosted checkout URL. Use whenever the user needs to browse what's for sale, bill a customer, collect a one-time payment, or start a subscription via a Stripe-hosted page. version: 0.2.0 emoji: 💳 homepage: https://stripe.com/docs/api/checkout/sessions metadata: openclaw: requires: env: [STRIPE_SECRET_KEY] bins: [python3] primaryEnv: STRIPE_SECRET_KEY envVars: STRIPE_SECRET_KEY: required: true description: > Stripe secret API key (starts with `sk_test_` for test mode or `sk_live_` for live mode). Create one at https://dashboard.stripe.com/apikeys. The skill makes server-side API calls and will not work with a publishable key (`pk_...`). install: uv: - stripe>=10.0.0 --- # Stripe Checkout & Product Lookup This skill does two things against the Stripe API: 1. **List/search products** in the Stripe product catalog — returns name, description, image, prices, and metadata. 2. **Create a [Stripe Checkout Session](https://stripe.com/docs/api/checkout/sessions/create)** and return the hosted payment URL the customer should be redirected to. A typical flow: call `list_products.py` to find the right `price_...` id(s), then pass them to `create_checkout_session.py` as `line_items`. ## Required credentials The agent host MUST expose **`STRIPE_SECRET_KEY`** in the environment before this skill is invoked. - Test mode key: `sk_test_...` (use during development; charges are not real) - Live mode key: `sk_live_...` (real money — only after explicit user confirmation) If `STRIPE_SECRET_KEY` is missing, stop and tell the user to set it. Do not prompt the user to paste the key into chat — secrets must come from the environment. Optional environment variables: | Variable | Purpose | | ------------------------- | ---------------------------------------------------------------------- | | `STRIPE_API_VERSION` | Pin a Stripe API version (defaults to the account's pinned version). | | `STRIPE_DEFAULT_SUCCESS_URL` | Fallback `success_url` when the user does not provide one. | | `STRIPE_DEFAULT_CANCEL_URL` | Fallback `cancel_url` when the user does not provide one. | ## When to use - "What products do we sell?" / "List our Stripe catalog" / "Find the price of X" - "Create a checkout link for X" - "Bill the customer $N for Y" - "Start a subscription to price `price_...`" - "Generate a Stripe payment page for these items" ## When NOT to use - The user wants to charge a saved card directly → use the PaymentIntents API instead (different skill). - The user wants to issue a refund, manage a subscription's lifecycle, or read reports → out of scope here. - The user wants to *create* or *edit* a product → out of scope; use the Stripe dashboard or a separate skill. ## Scripts | Script | Purpose | | ------------------------------------- | ------------------------------------------------------------- | | `scripts/list_products.py` | Query products (list / search / by id) + their prices | | `scripts/create_checkout_session.py` | Create a Checkout Session and return the hosted checkout URL | --- ## Listing products Call `scripts/list_products.py` with an optional JSON filter on stdin. With no input, it returns the first 10 active products. ### Input schema (all fields optional) ```json { "query": "shirt", "ids": ["prod_abc", "prod_def"], "active": true, "limit": 10, "starting_after": "prod_xyz", "include_inactive_prices": false } ``` - `query` — free-text search against product name. If the string contains a `:`, it is passed through as a raw [Stripe Search](https://stripe.com/docs/search) query (e.g. `metadata['sku']:'A-100' AND active:'true'`). Search results may lag writes by up to a minute. - `ids` — retrieve specific products by id (skips list/search). Up to ~50 is reasonable. - `active` — defaults to `true`. Pass `false` to include archived products, or `null` for both. - `limit` — 1–100, defaults to 10. - `starting_after` — pagination cursor (returned as `next_starting_after`). Only applies to plain list mode. - `include_inactive_prices` — defaults to `false`. Each returned product's `prices` array only contains active prices unless this is `true`. ### Example invocation ```bash echo '{"query": "shirt", "limit": 5}' | python3 scripts/list_products.py ``` ### Output ```json { "products": [ { "id": "prod_NabcXYZ", "name": "Cotton T-shirt", "description": "100% cotton, unisex", "image": "https://files.stripe.com/.../shirt-front.png", "images": ["https://files.stripe.com/.../shirt-front.png"], "metadata": {"sku": "TS-001", "color": "navy"}, "active": true, "default_price": "price_1Nabc...", "prices": [ { "id": "price_1Nabc...", "unit_amount": 1500, "currency": "usd", "recurring": null, "nickname": null, "active": true } ] } ], "has_more": false, "next_starting_after": null } ``` - `unit_amount` is in the smallest currency unit (cents for USD). - `image` is the first entry from `images` for convenience; use `images` if you need them all. - `metadata` is the product's free-form key/value map set in the Stripe dashboard. - When `has_more` is true, pass `next_starting_after` back as `starting_after` to get the next page. When the user asks about pricing for a product, prefer the `default_price` if set; otherwise, surface all `prices` so they can pick. --- ## Creating a Checkout Session Call `scripts/create_checkout_session.py` with a JSON payload on stdin. The script prints a JSON object with `url`, `id`, and `expires_at` on success. ### Input schema ```json { "mode": "payment | subscription | setup", "line_items": [ {"price": "price_123", "quantity": 1}, { "price_data": { "currency": "usd", "unit_amount": 1500, "product_data": {"name": "Custom T-shirt", "description": "Size M"} }, "quantity": 2 } ], "success_url": "https://example.com/success?session_id={CHECKOUT_SESSION_ID}", "cancel_url": "https://example.com/cancel", "customer_email": "[REDACTED_SECRET]", "client_reference_id": "optional-internal-id", "metadata": {"order_id": "1234"}, "allow_promotion_codes": true } ``` - `mode` defaults to `"payment"` (one-time). Use `"subscription"` when any line item references a recurring price. - Each `line_items[*]` entry MUST contain either `price` (existing Stripe Price ID) OR `price_data` (inline price), plus `quantity`. - `unit_amount` is in the smallest currency unit (cents for USD). - `success_url` is required by Stripe. Use `{CHECKOUT_SESSION_ID}` to receive the session id back as a query param. ### Example invocation ```bash echo '{ "mode": "payment", "line_items": [{"price": "price_1Nabc...", "quantity": 1}], "success_url": "https://example.com/success?session_id={CHECKOUT_SESSION_ID}", "cancel_url": "https://example.com/cancel" }' | python3 scripts/create_checkout_session.py ``` Successful output: ```json { "url": "https://checkout.stripe.com/c/pay/cs_test_...", "id": "cs_test_a1B2c3D4...", "expires_at": 1716595200, "mode": "payment", "livemode": false } ``` Return the `url` to the user — that is the link they (or their customer) should open to complete payment. ### Errors The script exits non-zero and prints a JSON `{"error": {...}}` object on failure. Common cases: - `auth_error` — `STRIPE_SECRET_KEY` is missing, malformed, or revoked. - `invalid_request` — bad `price` id, currency mismatch, missing `success_url`. - `validation_error` — the input JSON does not satisfy the schema. Surface the human-readable `message` to the user and suggest the obvious fix. Do not retry on `auth_error` or `validation_error`. ## Safety - Always confirm the **mode** (test vs live) and **total amount** with the user before creating a session in live mode. - Never log the full `STRIPE_SECRET_KEY`. The script reads it from the environment and never echoes it. - Treat the returned `url` as sensitive-ish: anyone with the link can pay. Share it only with the intended customer. ## References See [`references/checkout_options.md`](references/checkout_options.md) for the less-common Checkout Session fields (shipping, tax, automatic_payment_methods, etc.).

path: references/checkout_options.md
content: # Checkout Session — less-common options

All fields below pass straight through to `stripe.checkout.Session.create` and can be included in the JSON payload to `scripts/create_checkout_session.py`. Full reference: https://stripe.com/docs/api/checkout/sessions/create

## Customer

- `customer` — existing `cus_...` id. Use when the user already has a Customer.
- `customer_email` — pre-fill the email field (cannot be combined with `customer`).
- `client_reference_id` — your own internal id (max 200 chars). Returned via the `checkout.session.completed` webhook.

## Tax & shipping

- `automatic_tax: {"enabled": true}` — requires Stripe Tax to be configured on the account.
- `shipping_address_collection: {"allowed_countries": ["US", "CA"]}`
- `billing_address_collection: "required" | "auto"`

## Promotions & discounts

- `allow_promotion_codes: true` — show a "promotion code" field on the hosted page.
- `discounts: [{"coupon": "abc"}]` — apply a coupon directly (cannot combine with `allow_promotion_codes`).

## Payment methods & locale

- `payment_method_types: ["card", "us_bank_account", "link"]` — defaults to the account's enabled methods.
- `locale: "auto"` — or a specific locale like `"en"`, `"fr"`, `"es"`.

## Subscription-specific

When `mode = "subscription"`:

- `subscription_data.trial_period_days: 14`
- `subscription_data.metadata: {...}`
- Every `line_items[*].price` must reference a recurring Price.

## One-time payment specifics

When `mode = "payment"`:

- `payment_intent_data.setup_future_usage: "off_session"` — save the card for later.
- `payment_intent_data.statement_descriptor`
- `payment_intent_data.metadata`

## Setup mode

When `mode = "setup"`, do NOT pass `line_items`. The session collects a payment method without charging it; useful for saving a card before billing later.

## Expiration

- `expires_at` — Unix timestamp, must be 30 minutes to 24 hours in the future. Defaults to 24h.


path: scripts/create_checkout_session.py
content: #!/usr/bin/env python3
"""Create a Stripe Checkout Session from a JSON payload on stdin.

Reads STRIPE_SECRET_KEY from the environment. Prints a JSON object with the
hosted checkout `url`, session `id`, and `expires_at` on success, or
`{"error": {...}}` on failure (with a non-zero exit code).
"""

from __future__ import annotations

import json
import os
import sys
from typing import Any

try:
    import stripe
except ImportError:
    print(
        json.dumps(
            {
                "error": {
                    "type": "dependency_error",
                    "message": "The 'stripe' Python package is not installed. Install it with: pip install 'stripe>=10.0.0'",
                }
            }
        )
    )
    sys.exit(2)


ALLOWED_MODES = {"payment", "subscription", "setup"}


def _fail(error_type: str, message: str, **extra: Any) -> None:
    payload = {"error": {"type": error_type, "message": message, **extra}}
    print(json.dumps(payload))
    sys.exit(1)


def _validate(payload: dict[str, Any]) -> dict[str, Any]:
    if not isinstance(payload, dict):
        _fail("validation_error", "Input must be a JSON object.")

    mode = payload.get("mode", "payment")
    if mode not in ALLOWED_MODES:
        _fail(
            "validation_error",
            f"`mode` must be one of {sorted(ALLOWED_MODES)}, got {mode!r}.",
        )

    line_items = payload.get("line_items")
    if mode != "setup":
        if not isinstance(line_items, list) or not line_items:
            _fail("validation_error", "`line_items` must be a non-empty array.")
        for i, item in enumerate(line_items):
            if not isinstance(item, dict):
                _fail("validation_error", f"line_items[{i}] must be an object.")
            if "price" not in item and "price_data" not in item:
                _fail(
                    "validation_error",
                    f"line_items[{i}] must include either `price` or `price_data`.",
                )
            if "quantity" not in item and mode != "subscription":
                item["quantity"] = 1

    success_url = payload.get("success_url") or os.environ.get(
        "STRIPE_DEFAULT_SUCCESS_URL"
    )
    cancel_url = payload.get("cancel_url") or os.environ.get(
        "STRIPE_DEFAULT_CANCEL_URL"
    )
    if not success_url:
        _fail(
            "validation_error",
            "`success_url` is required (or set STRIPE_DEFAULT_SUCCESS_URL).",
        )

    args: dict[str, Any] = {"mode": mode, "success_url": success_url}
    if cancel_url:
        args["cancel_url"] = cancel_url
    if line_items is not None:
        args["line_items"] = line_items

    for optional in (
        "customer",
        "customer_email",
        "client_reference_id",
        "metadata",
        "allow_promotion_codes",
        "automatic_tax",
        "shipping_address_collection",
        "billing_address_collection",
        "payment_method_types",
        "locale",
        "subscription_data",
        "payment_intent_data",
        "expires_at",
    ):
        if optional in payload:
            args[optional] = payload[optional]

    return args


def main() -> None:
    [REDACTED_SECRET]"STRIPE_SECRET_KEY")
    if not [REDACTED_SECRET]
            "auth_error",
            "STRIPE_SECRET_KEY is not set. Export it before invoking the skill.",
        )
    if not api_key.startswith(("sk_test_", "sk_live_", "rk_")):
        _fail(
            "auth_error",
            "STRIPE_SECRET_KEY does not look like a Stripe secret key "
            "(expected prefix sk_test_, sk_live_, or rk_).",
        )

    stripe.[REDACTED_SECRET]
    api_version = os.environ.get("STRIPE_API_VERSION")
    if api_version:
        stripe.api_version = api_version

    raw = sys.stdin.read()
    if not raw.strip():
        _fail("validation_error", "No JSON payload received on stdin.")
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError as e:
        _fail("validation_error", f"Invalid JSON on stdin: {e}")

    args = _validate(payload)

    try:
        session = stripe.checkout.Session.create(**args)
    except stripe.error.AuthenticationError as e:
        _fail("auth_error", str(e))
    except stripe.error.InvalidRequestError as e:
        _fail("invalid_request", str(e), param=getattr(e, "param", None))
    except stripe.error.StripeError as e:
        _fail("stripe_error", str(e))

    print(
        json.dumps(
            {
                "url": session.url,
                "id": session.id,
                "expires_at": session.expires_at,
                "mode": session.mode,
                "livemode": session.livemode,
            }
        )
    )


if __name__ == "__main__":
    main()


path: scripts/list_products.py
content: #!/usr/bin/env python3
"""Query Stripe products and return name, description, price, image, metadata.

Reads STRIPE_SECRET_KEY from the environment. Accepts a JSON filter object
on stdin (all fields optional); prints `{"products": [...], "has_more": bool,
"next_starting_after": str|null}` on success or `{"error": {...}}` on failure.
"""

from __future__ import annotations

import json
import os
import sys
from typing import Any

try:
    import stripe
except ImportError:
    print(
        json.dumps(
            {
                "error": {
                    "type": "dependency_error",
                    "message": "The 'stripe' Python package is not installed. Install it with: pip install 'stripe>=10.0.0'",
                }
            }
        )
    )
    sys.exit(2)


def _fail(error_type: str, message: str, **extra: Any) -> None:
    print(json.dumps({"error": {"type": error_type, "message": message, **extra}}))
    sys.exit(1)


def _price_to_dict(price: Any) -> dict[str, Any]:
    return {
        "id": price.id,
        "unit_amount": price.unit_amount,
        "currency": price.currency,
        "recurring": (
            {
                "interval": price.recurring.interval,
                "interval_count": price.recurring.interval_count,
            }
            if getattr(price, "recurring", None)
            else None
        ),
        "nickname": getattr(price, "nickname", None),
        "active": price.active,
    }


def _product_to_dict(product: Any, prices: list[Any]) -> dict[str, Any]:
    images = list(getattr(product, "images", []) or [])
    return {
        "id": product.id,
        "name": product.name,
        "description": product.description,
        "image": images[0] if images else None,
        "images": images,
        "metadata": dict(product.metadata or {}),
        "active": product.active,
        "default_price": (
            product.default_price
            if isinstance(product.default_price, str)
            else (product.default_price.id if product.default_price else None)
        ),
        "prices": [_price_to_dict(p) for p in prices],
    }


def _fetch_prices(product_id: str, include_inactive: bool) -> list[Any]:
    kwargs: dict[str, Any] = {"product": product_id, "limit": 100}
    if not include_inactive:
        kwargs["active"] = True
    return list(stripe.Price.list(**kwargs).auto_paging_iter())


def main() -> None:
    [REDACTED_SECRET]"STRIPE_SECRET_KEY")
    if not [REDACTED_SECRET]"auth_error", "STRIPE_SECRET_KEY is not set. Export it before invoking the skill.")
    if not api_key.startswith(("sk_test_", "sk_live_", "rk_")):
        _fail(
            "auth_error",
            "STRIPE_SECRET_KEY does not look like a Stripe secret key "
            "(expected prefix sk_test_, sk_live_, or rk_).",
        )

    stripe.[REDACTED_SECRET]
    api_version = os.environ.get("STRIPE_API_VERSION")
    if api_version:
        stripe.api_version = api_version

    raw = sys.stdin.read().strip()
    payload: dict[str, Any] = {}
    if raw:
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError as e:
            _fail("validation_error", f"Invalid JSON on stdin: {e}")
        if not isinstance(payload, dict):
            _fail("validation_error", "Input must be a JSON object.")

    query = payload.get("query")
    ids = payload.get("ids")
    active = payload.get("active", True)
    include_inactive_prices = bool(payload.get("include_inactive_prices", False))
    limit = int(payload.get("limit", 10))
    if limit < 1 or limit > 100:
        _fail("validation_error", "`limit` must be between 1 and 100.")
    starting_after = payload.get("starting_after")

    has_more = False
    next_starting_after: str | None = None
    products_raw: list[Any] = []

    try:
        if ids:
            if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
                _fail("validation_error", "`ids` must be a list of Stripe product IDs.")
            for pid in ids:
                products_raw.append(stripe.Product.retrieve(pid))

        elif query:
            if not isinstance(query, str):
                _fail("validation_error", "`query` must be a string.")
            # Stripe search syntax: https://stripe.com/docs/search
            # Treat a bare term as a name substring search; pass through if it
            # already contains a `:` (assume the caller wrote a real query).
            if ":" not in query:
                escaped = query.replace('"', '\\"')
                q = f'name:"{escaped}"'
                if active is not None:
                    q = f'{q} AND active:"{str(bool(active)).lower()}"'
            else:
                q = query
            result = stripe.Product.search(query=q, limit=limit)
            products_raw = list(result.data)
            has_more = bool(getattr(result, "has_more", False))
            next_starting_after = getattr(result, "next_page", None)

        else:
            kwargs: dict[str, Any] = {"limit": limit}
            if active is not None:
                kwargs["active"] = bool(active)
            if starting_after:
                kwargs["starting_after"] = starting_after
            result = stripe.Product.list(**kwargs)
            products_raw = list(result.data)
            has_more = bool(getattr(result, "has_more", False))
            if has_more and products_raw:
                next_starting_after = products_raw[-1].id

    except stripe.error.AuthenticationError as e:
        _fail("auth_error", str(e))
    except stripe.error.InvalidRequestError as e:
        _fail("invalid_request", str(e), param=getattr(e, "param", None))
    except stripe.error.StripeError as e:
        _fail("stripe_error", str(e))

    products_out = [
        _product_to_dict(p, _fetch_prices(p.id, include_inactive_prices))
        for p in products_raw
    ]

    print(
        json.dumps(
            {
                "products": products_out,
                "has_more": has_more,
                "next_starting_after": next_starting_after,
            }
        )
    )


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)