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:
- 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 |
| 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
orderSuccessPageUrlin 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: pendingindefinitely. - 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-affiliateskill.
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_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.10.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 stockis 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 handling400 OUT_OF_STOCKat checkout (§3).
- 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.
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) →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: { 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 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. 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:
- 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, naming the page to pay on: "open the
checkoutUrlyour 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 exercisedigital_product.checkout.failedby signing that payload withscripts/sign_callback.mjsand 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 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. - Test mode lives on the checkout session, not on the product. The test card works only on the
checkoutUrlyou created with apcs_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_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.