SuperAPI AI Directory

portaly-product

General & other

View repository

Updated 10-15 21:38

About this skill

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.

The flow is a redirect-based hosted 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
Card to enter at checkout The buyer's real card The test card the checkout page prints
Where a completed order lands The creator's real orders, revenue and payouts A separate sandbox ledger — off the settlement chain, but listed in the Payment admin with the orders table set to Test

The API key is shared with the portaly-payment skill (creator subscriptions). One key, two products. Test keys run the whole checkout without charging real money — develop against a test key and swap to a live key for production, but read Test mode below for what a test purchase does not do.

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.

Test mode

Never invent a card number. A test-mode checkout page prints the test card to use, in a highlighted box just below the card fields — tell the user to read it off the page. There is no card number in this skill on purpose: the page is the single source of truth for it.

That card only works on the checkoutUrl this skill creates. Test mode is a property of the checkout session, not of the product. The same product is also on sale on the creator's own Portaly page (portaly.cc/<slug>), and that checkout is always real money with no test mode and no test-card hint — the test card is rejected there with a raw gateway error (e.g. Failured3DS). If the user reports "I tested it and got a weird error", first ask which page they paid on.

A test-mode purchase does not rehearse what happens after the charge. The charge really executes (against the TapPay sandbox, so no money moves) and the webhook really fires, but the order is written to a separate sandbox ledger and every downstream step is skipped. Tell the creator before they test, so they don't go hunting for something that was never meant to be there:

  • No confirmation email to the buyer. Portaly's order email fires off the live order collection only, so no confirmation email is sent. Verify your own handler's output rather than watching an inbox. The buyer's email-verification code, if your checkout requires one, is still sent in test mode — that email arriving is not evidence the confirmation email will.
  • The orderSuccessPageUrl in the callback does not resolve to the order. That page looks the order up in the live collection only.
  • No invoice — the payment record stays at invoiceStatus: pending indefinitely.
  • Nothing reaches the creator's revenue, balance or payouts. The sandbox ledger is off the settlement chain.
  • No review invite, because sandbox orders are deliberately unreviewable.
  • No affiliate commission — though that one is not a test-mode limit: orders created through this API carry none in either mode. Commission is a subscription-plan feature; see the portaly-affiliate skill.

Where a test order is visible. In the Portaly Payment admin (https://portaly.cc/admin/creator-subscription), switch the orders table's Live/Test toggle to Test — it lists these orders and can refund them. The page's own tabs are Subscriptions and Orders, so don't send the creator hunting for a "test tab". GET /api/digital-products/orders with the test key returns them too — hand the user one of those two checks, not the creator's main revenue view.

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:

# 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.10.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
    • stock is what was available when you read it, not a hold. Another buyer can take the last unit before yours pays, so treat it as display only and keep handling 400 OUT_OF_STOCK at checkout (§3).
  • 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.

2b. Taking a product off sale

The creator normally manages the catalog in their Portaly dashboard, but if their site needs its own on/off switch, POST /api/digital-products/{productId}/status with {"isActive": false} takes a product off sale (send true to put it back, or omit the field to flip the current state). It needs a full-access key — a key issued for integration only gets 403 KEY_SCOPE_FORBIDDEN, which is the correct outcome, not something to work around.

Deactivating refuses new checkouts for that product and hides it from the list endpoint; it does not touch orders already placed, so existing buyers keep their deliverables. Do not use this to model "sold out" — stock is already enforced at checkout and a stock-limited product stops selling on its own.

See references/api-contract.md for the full contract.

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.

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: { tracking: JSON.stringify({ userId: '...', cartId: '...' }) },  // your own data goes under `tracking` as one string (≤ 500 chars) — the Python/Go v1 adapters fail closed on other 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).

OUT_OF_STOCK can hit a product your grid just showed as available — creating a session does not reserve anything, and the last unit can go to another buyer in between. Handle it as an expected outcome: refresh that item and tell the buyer it just sold out, rather than surfacing it as a failure of their action.

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 is echoed into the signed callback body. v1 sorts object keys with JavaScript localeCompare, which the Python and Go adapters cannot reproduce for arbitrary keys, so they fail closed on any metadata key outside the committed schema. Put your own data under the tracking key as a JSON string (metadata: { tracking: JSON.stringify({ userId, cartId }) }): tracking is committed in the vectors and verifies under every adapter, and since the list governs keys rather than values one key carries any structure. The string counts toward the 500-character limit on each metadata value, so keep it to the fields you will actually read back. For any other custom key, verify with the Node/WebCrypto adapter.

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. In test mode none of these emails are sent — read Test mode above before telling anyone to watch an inbox.

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, metadata. 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, naming the page to pay on: "open the checkoutUrl your backend returns and pay with the test card the page shows you; do it once with a single item and once with a bundle, then refund one of them in Portaly admin with the orders table switched to Test". Add one step for the failure branch: the checkout page offers no declining card, so exercise digital_product.checkout.failed by signing that payload with scripts/sign_callback.mjs and POSTing it at your own webhook, rather than trying to fail a real charge.

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.
  • Test mode lives on the checkout session, not on the product. The test card works only on the checkoutUrl you created with a pcs_test_ key. Buying the same product from the creator's own Portaly page is always a real charge, and the test card fails there with a raw gateway error — never tell the user to "just try it on Portaly" as a way to test the integration.
  • 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.