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 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_KEYin.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" # LinuxAfter 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
.envthemselves (never via chat — see Guardrails):PORTALY_API_KEY=pcs_test_xxx PORTALY_CALLBACK_SECRET=xxx - Fetch products:
GET /api/digital-productswithAuthorization: Bearer ${PORTALY_API_KEY}. - Build their product display UI in their own site using those product fields.
- On "checkout" click, call
POST /api/digital-products/checkout-sessionswithitems[],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, verifyx-portaly-eventmatches the authenticated body event, and persist eachorderIdfrom the callback'sorders[]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-paymentskill. 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_KEYandPORTALY_CALLBACK_SECRETdirectly into.envthemselves. - Verify
.gitignorecontains.envbefore 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:
- 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
PORTALY_API_KEYis 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" } versionis this skill'smetadata.versionfrom 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
skillNameandversion. If the call fails, ignore it and continue — it never blocks anything. - On success, check the response's
data.availablearray — 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 onnpx 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-linedescription. This is discovery only — do not runnpx skills addyourself 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
availablelist — surface it once per session total, not once per skill. Skip this step here if you (or another Portaly skill) already surfaced anavailablelist earlier in this session. - If
data.availableis 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 imagetitle.text(fallback toname) → headline.title.colorandtitle.alignare creator-configured styling.- Price:
- Primary price → always
effectivePrice(already accounts for sale / countdown / free). - If
effectivePrice < price→ showpricestruck-through next to it as the "list price". - For badges (optional UX):
priceStatus === 'isSale'→ "Sale" badge;priceStatus === 'isCountdown'→ countdown banner driven bycountdownSetting[];productMode === 'free'→ "Free" label. - Do not roll your own
sale ?? price— that misreads countdown / free products. Same fortotalAmountwhen building a checkout session: sumeffectivePrice(then apply any bundle discount), notsale ?? price.
- Primary price → always
stock+isStock+isShowStock:- if
!isStock→ don't show stock - if
isStock && stock === 0→ out of stock; usestockButtonNameas the CTA label - if
isStock && isShowStock→ show remaining count
- if
buttonName→ CTA button label (defaults to "Buy" / "立即購買" if missing)productImages→ additional gallery images for detail pagesvideoUrl/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) →totalAmountmust be> 0. - Bundle is entirely free items (all
effectivePrice <= 0) →totalAmountmust be exactly0. 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: { 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 witheventas the checkout idempotency key — see below)orders[](each hasorderId,productId,allocatedAmount,orderSuccessPageUrl)merchantOrderNumbermetadata
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
.envkeys 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
pcs_live_key creates real, chargeable checkout sessions. If the loaded key starts withpcs_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_KEYandPORTALY_CALLBACK_SECRETin.envthemselves. - Always verify
.gitignoreincludes.envbefore suggesting any commit. - Always verify webhook signatures before acting on a webhook payload. Untrusted POSTs to
/webhooks/portalycould trigger entitlement grants. - Always check
x-portaly-timestampfreshness (reject if more than 5 minutes from now in either direction; the symmetric window tolerates ordinary clock skew). - Always serve
callbackUrlover HTTPS. - Use
event + sessionId(checkout completed/failed) andevent + 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
successRedirectUrlas proof of payment. Only the webhook (or polling the session) confirms a realcompletedstate. - 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-productscall 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 importnode:crypto(Cloudflare/Vercel Edge, Deno, InsForge edge functions). Same scheme + byte-identicalstableJson, verifies viacrypto.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.goandscripts/verify_callback_test.go— Go adapter plus its production-derived and fail-closed tests.