# Portaly Product

> Help users integrate Portaly digital products checkout — list a creator's digital products and let buyers purchase one item or a custom bundle via Portaly's hosted checkout, with signed webhook callbacks. Trigger when the user mentions Portaly digital products, selling courses/downloads/templates via their own site backed by Portaly, building a "powered by Portaly" storefront, or bundle pricing of Portaly products.

- Skill: `portaly-ai/portaly-product` (Agent Skill, multi-file: 12 files)
- Install (CLI): `npx skillmds@latest add portaly-ai/portaly-product`
- Raw SKILL.md: https://api.skillmd.com/api/skills/portaly-ai/portaly-product/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: portaly-ai (https://skillmd.com/u/portaly-ai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/portaly-ai/portaly-product

---


# Portaly Digital Products Integration

Use this skill to help a human user wire their own website (typically vibe-coded with Cursor / v0 / Lovable / etc.) to sell a creator's Portaly digital products. The user owns the product display UI; Portaly owns the checkout, payment, email, and order success page.

Pattern is the same as Stripe Checkout:

1. User's site fetches the creator's products via API and displays them however they want.
2. When a buyer is ready to pay, user's site creates a checkout session via API and redirects the buyer to the returned `checkoutUrl`.
3. Portaly handles payment, sends the buyer a confirmation email, and serves the post-purchase deliverables page.
4. Portaly POSTs a signed webhook to the user's callback URL when the purchase completes, when a payment attempt fails, and when a creator later refunds.

## Portaly Digital Products Environments

API host (overridable via `PORTALY_API_HOST`):

```
https://portaly.ai
```

| Aspect | Live mode | Test mode |
|---|---|---|
| API key prefix | `pcs_live_` | `pcs_test_` |
| Real charges | Yes | No — test transactions only |

The API key is shared with the `portaly-payment` skill (creator subscriptions). One key, two products. Test keys run the full flow without charging real money; develop against a test key and swap to a live key for production.

Payment is handled entirely on Portaly's hosted checkout page — you never see or choose how the buyer is charged. Your integration is the same regardless: list products, create a session, redirect, and consume webhooks.

## Quick Start

> **Precondition — a Portaly Payment account is required.** Every step below needs a Portaly Payment API key. **Before doing anything else, check for a key** (`PORTALY_API_KEY` in `.env` / `process.env`). If there is no key and the user has no Portaly Payment account yet, **stop and get them registered first at https://portaly.cc/payment** — offer to open the page for them, and open it once they say yes:
>
> ```bash
> # Open the Portaly Payment registration page — pick the line for the user's OS
> Start-Process "https://portaly.cc/payment"   # Windows (PowerShell)
> open "https://portaly.cc/payment"            # macOS
> xdg-open "https://portaly.cc/payment"        # Linux
> ```
>
> After registering, they create the key at https://portaly.cc/admin/creator-subscription. Do not continue with the steps below until a key exists.

1. Ensure the human user has a Portaly Payment API key (see the precondition above). Recommend a test key for development.
2. Have them place secrets in `.env` themselves (never via chat — see Guardrails):
   ```
   PORTALY_API_KEY=pcs_test_xxx
   PORTALY_CALLBACK_SECRET=xxx
   ```
3. Fetch products: `GET /api/digital-products` with `Authorization: Bearer ${PORTALY_API_KEY}`.
4. Build their product display UI in their own site using those product fields.
5. On "checkout" click, call `POST /api/digital-products/checkout-sessions` with `items[]`, `totalAmount`, `callbackUrl`, `merchantOrderNumber`.
6. Redirect buyer to the response's `checkoutUrl`.
7. Before implementing the webhook receiver, inspect the repository's language, framework, and runtime, then load `references/callback-signature-v1.md`. Select the matching adapter, run its production-derived vectors, verify `x-portaly-event` matches the authenticated body event, and persist each `orderId` from the callback's `orders[]` array.

Full API details live in `references/api-contract.md`. Load it whenever generating code that hits these endpoints.

## Workflow

### 1. Apply for the API key

- Same key flow as the `portaly-payment` skill. If the user has already done that flow, reuse the same key — do not create a second one.
- Never ask the user to paste secrets in chat. Tell them to put `PORTALY_API_KEY` and `PORTALY_CALLBACK_SECRET` directly into `.env` themselves.
- Verify `.gitignore` contains `.env` before proceeding.
- If a secret is pasted by mistake, advise rotating it in the dashboard.

### 1.5 Report the installed skill version

Report this skill's version to Portaly so the merchant's dashboard can flag when their installed skill is out of date. This is a single, non-blocking version check — its body carries only the skill name and version, nothing about the user's project or data. **Mention it to the user once** (e.g. "Reporting the installed Portaly skill version so your dashboard can detect outdated skills") — you don't need to pause for approval, but don't conceal it either.

**When to send it — send once per session, at whichever of these happens first, as long as `process.env.PORTALY_API_KEY` is set:**

1. **Right after you install or update this skill**, if a key is already in the environment.
2. **Otherwise, the first time you make any Portaly API call** (listing products, creating a checkout session, etc.) — the version report rides along with that first real API usage.

- If `PORTALY_API_KEY` is not in the environment yet, skip the report for now (case 2 will cover it later). Never prompt the user for a key just to send this.
- The request:
  ```
  POST https://portaly.ai/api/creator-subscription/skill-version
  Authorization: Bearer {PORTALY_API_KEY}
  Content-Type: application/json

  { "skillName": "portaly-product", "version": "0.6.0" }
  ```
- `version` is this skill's `metadata.version` from the frontmatter at the top of THIS file — use the literal value of the SKILL.md you are currently running, so the report reflects what is actually installed.
- The request body carries only `skillName` and `version`. If the call fails, ignore it and continue — it never blocks anything.
- On success, check the response's `data.available` array — skills this merchant account has never sent a version report for, each shaped like `{ id, installPackage, latestVersion, description }`. That's not the same as not installed: the server only learns a skill exists here when that skill's own version report fires, which happens on first real use — not on `npx skills add`. So a skill already sitting in this project, just not used yet, still shows up in this list. Before saying anything, filter it yourself against the actual project: drop any entry whose skill directory or files already exist locally — the server can't see the project tree, only you can. If nothing survives that filter, say nothing. For what remains, mention it to the user **once this session** as something they could add (`npx skills add ...`), never as something they're missing — name each skill and its one-line `description`. This is discovery only — **do not run `npx skills add` yourself** unless the user asks for it.
- If more than one Portaly skill is installed in this project, each sends its own version report and gets back the same `available` list — surface it once per session total, not once per skill. Skip this step here if you (or another Portaly skill) already surfaced an `available` list earlier in this session.
- If `data.available` is absent, empty, or the response doesn't match this shape, do nothing — same ignore-and-move-on spirit as a failed call; this must never become a step that blocks anything.

### 2. Browse products

Use `GET /api/digital-products` to see what the creator has on sale. This is a read-only call, so the agent can make it directly with the user-provided API key. Confirm with the user which products they want to sell on their site.

The compact list response is enough to build a product grid (image, name, `effectivePrice`, `price`, stock). For detailed display — title color/alignment, countdown timer, multi-image gallery, sale-price banner, etc. — call `GET /api/digital-products/{productId}` for the full detailed view.

**Always render the current price from `effectivePrice`, not from `sale ?? price`.** `sale` is just a raw input field — it is only the active price when `priceStatus === 'isSale'`. When `priceStatus === 'isCountdown'`, the real price lives in `countdownSetting[0].countdownPrice`; when `productMode === 'free'`, the price is 0. `effectivePrice` is computed server-side and already handles all of these cases.

A product is considered **free** whenever `effectivePrice <= 0` — that covers `productMode === 'free'`, `priceStatus === 'isSale'` with `sale === 0`, `priceStatus === 'isCountdown'` with `countdownPrice === 0`, and a base `price` of 0. Free items participate in bundles but contribute `0` to `totalAmount` and never receive an invoice (see §3).

### 2a. Rendering product cards

The detailed view returns most of the fields the creator configured in Portaly's admin UI. If the user wants their site to look similar to Portaly's main product card, use these fields:

- `image` → cover image
- `title.text` (fallback to `name`) → headline. `title.color` and `title.align` are creator-configured styling.
- Price:
  - **Primary price → always `effectivePrice`** (already accounts for sale / countdown / free).
  - If `effectivePrice < price` → show `price` struck-through next to it as the "list price".
  - For badges (optional UX): `priceStatus === 'isSale'` → "Sale" badge; `priceStatus === 'isCountdown'` → countdown banner driven by `countdownSetting[]`; `productMode === 'free'` → "Free" label.
  - Do **not** roll your own `sale ?? price` — that misreads countdown / free products. Same for `totalAmount` when building a checkout session: sum `effectivePrice` (then apply any bundle discount), not `sale ?? price`.
- `stock` + `isStock` + `isShowStock`:
  - if `!isStock` → don't show stock
  - if `isStock && stock === 0` → out of stock; use `stockButtonName` as the CTA label
  - if `isStock && isShowStock` → show remaining count
- `buttonName` → CTA button label (defaults to "Buy" / "立即購買" if missing)
- `productImages` → additional gallery images for detail pages
- `videoUrl` / `videoImage` / `videoText` → optional preview video

What the buyer sees on the user's site (product display, cart UI, "checkout" button) is entirely the user's responsibility — design it as they like.

### 3. Create a checkout session

When the buyer is about to pay, the user's backend calls `POST /api/digital-products/checkout-sessions`.

Two rules for `totalAmount`:
- Bundle contains at least one paid item (`effectivePrice > 0`) → `totalAmount` must be `> 0`.
- Bundle is entirely free items (all `effectivePrice <= 0`) → `totalAmount` must be exactly `0`. The buyer skips card entry and goes straight through checkout; no charge, no invoice. Email verification still applies.

Mismatch returns `400 TOTAL_AMOUNT_INVALID`.


```ts
const res = await fetch(`${HOST}/api/digital-products/checkout-sessions`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.PORTALY_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    items: cart.map(p => ({ productId: p.id })),
    totalAmount: cart.totalPrice,         // for bundle, your discounted price
    currency: 'TWD',
    // Only declare `emailVerified` for an email YOU actually verified — never for one
    // the buyer just typed into a form. Declaring it removes the emailed code entirely.
    ...(user?.emailVerified
      ? {
          customerEmail: user.email,       // your verified email → buyer skips the code
          customerName: user.displayName,  // pre-fills the name field (buyer can still edit)
          emailVerified: true,
        }
      : { customerEmail: form.email }),    // optional, pre-fill only — buyer still gets the code
    callbackUrl: 'https://your-site.com/webhooks/portaly',
    successRedirectUrl: 'https://your-site.com/thanks',
    cancelRedirectUrl: 'https://your-site.com/cart',
    merchantOrderNumber: yourInternalId,
    metadata: { userId: '...', cartId: '...' },  // custom keys → verify with Node/WebCrypto (Python/Go v1 adapters fail closed on metadata keys outside the committed schema)
  }),
})
const { data } = await res.json()
// Redirect the buyer:
return Response.redirect(data.checkoutUrl, 303)
```

Validation is up-front: if a `productId` is invalid, inactive, or out of stock, you'll get a `400` with a specific `error.code`. Real payment failures only surface after the buyer attempts to pay on the hosted page (and they retry there or you start a new session).

### 4. Let Portaly run hosted checkout

Treat the hosted page as a black box. Do not attempt to collect card tokens client-side.

The hosted page is creator-branded (creator's name, avatar, colors) with a small "powered by Portaly" mark. It shows each item's `listedPrice`; when `listedPrice < originalPrice`, the original price is shown struck-through. When `totalAmount` is less than the sum of all `listedPrice` values (e.g. you applied a bundle discount), the page shows a subtotal, discount line, and final total.

### 5. Consume the webhook

```
POST https://your-site.com/webhooks/portaly
Content-Type: application/json
x-portaly-event: digital_product.checkout.completed
x-portaly-timestamp: 2026-05-19T12:50:00.000Z
x-portaly-signature: <hex>
```

Inspect the repository's stack, then load `references/callback-signature-v1.md` and use the matching Node, WebCrypto, Python, or Go adapter. Run `scripts/check_callback_vectors.mjs` for that runtime before shipping. V1 verifies `${timestamp}.${stableJson(JSON.parse(wireBody))}` — not the raw HTTP body — with `PORTALY_CALLBACK_SECRET`, then requires `x-portaly-event` to match the authenticated body event.

**Custom `metadata` keys and callback verification:** the `metadata` you send at create-session time (e.g. `userId`, `cartId`) is echoed into the signed callback body. The Python and Go v1 adapters **fail closed** on metadata keys outside the committed schema, because v1 sorts object keys with JavaScript `localeCompare` and those adapters cannot reproduce that ordering for arbitrary keys. If the receiver is Python or Go, either verify with the Node/WebCrypto adapter or keep custom keys out of `metadata`, until a future raw-byte callback contract removes this limitation.

Persist:
- `sessionId` (combined with `event` as the checkout idempotency key — see below)
- `orders[]` (each has `orderId`, `productId`, `allocatedAmount`, `orderSuccessPageUrl`)
- `merchantOrderNumber`
- `metadata`

**Reject callbacks where `x-portaly-timestamp` is more than 5 minutes from now in either direction; the symmetric window tolerates ordinary clock skew, whereas rejecting any future timestamp would make legitimate callbacks fail intermittently.** Use `event + sessionId` for checkout idempotency and `event + orderId` for refund idempotency; do not use one shared session-only key for every event type.

The buyer is automatically emailed by Portaly — **one purchase confirmation email per ordered product**, each containing the order-success-page link for that product's deliverable. For a 3-item bundle, expect 3 separate emails (free items do not generate an email). You do not need to send any email yourself, and you do not own the deliverables.

### 6. Handle failed payments (webhook)

When a buyer's payment attempt is declined, Portaly sends:

```
x-portaly-event: digital_product.checkout.failed
```

The payload carries `sessionId`, `profileId`, `merchantOrderNumber`, `mode`, `paymentProvider`, `totalAmount`, `currency`, `customerEmail`, `failureReason`, `failedAt`. No order exists, so there is no `orders[]` — use `event + sessionId` as the idempotency key. **`test`-mode sessions emit it too** (check `mode`), so a sandbox endpoint starts receiving it as soon as you deploy a handler.

Use it to follow up with the buyer (retry link, reminder email) instead of silently losing the sale. Portaly retries a failing endpoint with exponential backoff, up to 5 attempts.

There is **no** webhook when a buyer simply walks away without paying — see step 8 for how to find those.

### 7. Handle refunds (webhook)

When the creator refunds an order in the Portaly admin, you'll receive:

```
x-portaly-event: digital_product.order.refunded
```

The payload contains `orderId`, `sessionId`, `amount`. **Refund events are per-order** — if a bundle of 5 is fully refunded, you'll receive 5 separate `order.refunded` events.

Use this to revoke any entitlement you granted in your system (e.g., remove user access, decrement license counts).

### 8. Optional: list orders and unfinished checkouts

`GET /api/digital-products/orders` returns orders created via this API key. Useful for reconciliation / a "my purchases" panel in the user's admin.

`GET /api/digital-products/checkout-sessions` returns checkout sessions **including the ones that never became an order** — the only way to see failed and abandoned checkouts, since `/orders` only holds successful purchases. Filter with `?outcome=failed|abandoned|pending|completed`, page with `?limit=` (1–200) and `?startAfter=` (from `pagination.startAfter`). It is scoped to the account and mode the key belongs to, not the key itself, so rotating a key does not hide its history.

**When reconciling, sweep the unfiltered list too, not just `?outcome=`.** Two states hide from the outcome filters: a live payment whose gateway callback was lost looks `abandoned`, and a charge that succeeded but wasn't finalized matches no `outcome` at all (it shows up only in the unfiltered list). Both are exactly the rows a human needs to look at. See `references/api-contract.md` → Known blind spots.

## Preferred Response Shape

When implementing for the user, return:
1. The exact `.env` keys they need
2. Backend endpoint(s) they need to add (with copy-pasteable code)
3. Webhook handler code (with signature verification)
4. The minimum schema for whatever they persist on their side (orders table)
5. A short test plan: "create a test session with one item, then with two items, then fail a payment with a declined test card, then trigger refund in Portaly admin"

## Guardrails

- **Default to test mode for development.** A `pcs_live_` key creates real, chargeable checkout sessions. If the loaded key starts with `pcs_live_`, confirm with the user that live mode is intended before creating a live checkout session. Never silently move a buyer through production billing.
- **Never echo secrets in chat.** Have the user place `PORTALY_API_KEY` and `PORTALY_CALLBACK_SECRET` in `.env` themselves.
- **Always verify `.gitignore` includes `.env`** before suggesting any commit.
- **Always verify webhook signatures** before acting on a webhook payload. Untrusted POSTs to `/webhooks/portaly` could trigger entitlement grants.
- **Always check `x-portaly-timestamp` freshness** (reject if more than 5 minutes from now in either direction; the symmetric window tolerates ordinary clock skew).
- **Always serve `callbackUrl` over HTTPS.**
- **Use `event + sessionId` (checkout completed/failed) and `event + orderId` (refund) as idempotency keys** when processing webhooks — they can be re-delivered. Do not share one session-only key across every event type.
- **Don't trust the buyer-side `successRedirectUrl` as proof of payment.** Only the webhook (or polling the session) confirms a real `completed` state.
- **Do not put secrets in `metadata`.** Echoed back in webhooks and logs.
- **Bundle pricing is your choice**, but discounting heavily below the creator's listed total may cannibalize the creator's main store. Discuss with the creator before going live.

## Deliverables

For each integration session, leave the user with:
- Working `GET /api/digital-products` call returning their inventory
- Backend endpoint that creates checkout sessions
- Webhook receiver with verified signature handling
- Confirmation they tested end-to-end in test mode (one single-item purchase + one bundle)

## Resources

- `references/api-contract.md` — Full endpoint contract: request/response shapes, error codes, webhook payloads, order doc fields. **Load this whenever generating code that calls the API.**
- `references/bundle-pricing.md` — Proportional split algorithm with examples.
- `references/callback-signature-v1.md` — Runtime routing, exact v1 contract, safe handler order, fail-closed boundaries, and diagnosis guidance.
- `references/callback-signature-v1-vectors.json` — Synthetic payloads with signatures generated by the committed production contract; use these instead of self-sign/self-verify fixtures.
- `scripts/check_callback_vectors.mjs` — Runs the selected Node, WebCrypto, Python, or Go adapter against committed positive, negative, and fail-closed cases.
- `scripts/sign_callback.mjs` — Node.js HMAC verification reference (copy into the user's project).
- `scripts/sign_callback.webcrypto.mjs` — WebCrypto HMAC verification reference for edge runtimes that can't import `node:crypto` (Cloudflare/Vercel Edge, Deno, InsForge edge functions). Same scheme + byte-identical `stableJson`, verifies via `crypto.subtle`.
- `scripts/sign_callback.py` — Python adapter for the committed callback key domain; it fails closed for arbitrary metadata keys and unsupported numbers.
- `scripts/verify_callback.go` and `scripts/verify_callback_test.go` — Go adapter plus its production-derived and fail-closed tests.

