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:
- User's site fetches the creator's products via API and displays them however they want.
- When a buyer is ready to pay, user's site creates a checkout session via API and redirects the buyer to the returned
checkoutUrl.
- Portaly handles payment, sends the buyer a confirmation email, and serves the post-purchase deliverables page.
- 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 PORTALYAPIHOST):
https://portaly.ai
| Aspect |
Live mode |
Test mode |
| API key prefix |
pcslive |
pcstest |
| 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 (PORTALYAPIKEY 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.
- Ensure the human user has a Portaly Payment API key (see the precondition above). Recommend a test key for development.
- Have them place secrets in
.env themselves (never via chat — see Guardrails):
`` PORTALYAPIKEY=pcstestxxx PORTALYCALLBACKSECRET=xxx ``
- Fetch products:
GET /api/digital-products with Authorization: Bearer ${PORTALYAPIKEY}.
- Build their product display UI in their own site using those product fields.
- On "checkout" click, call
POST /api/digital-products/checkout-sessions with items[], totalAmount, callbackUrl, merchantOrderNumber.
- Redirect buyer to the response's
checkoutUrl.
- 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
PORTALYAPIKEY and PORTALYCALLBACKSECRET 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.PORTALYAPIKEY is set:
- Right after you install or update this skill, if a key is already in the environment.
- 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
PORTALYAPIKEY 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 {PORTALYAPIKEY} 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 TOTALAMOUNTINVALID.
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/checkcallbackvectors.mjs for that runtime before shipping. V1 verifies ${timestamp}.${stableJson(JSON.parse(wireBody))} — not the raw HTTP body — with PORTALYCALLBACKSECRET, 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:
- The exact
.env keys they need
- Backend endpoint(s) they need to add (with copy-pasteable code)
- Webhook handler code (with signature verification)
- The minimum schema for whatever they persist on their side (orders table)
- 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
pcslive key creates real, chargeable checkout sessions. If the loaded key starts with pcslive, 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
PORTALYAPIKEY and PORTALYCALLBACKSECRET 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/checkcallbackvectors.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/verifycallback.go and scripts/verifycallback_test.go — Go adapter plus its production-derived and fail-closed tests.