Portaly Affiliate (Buyer Promotion)
Use this skill to turn on buyer promotion for a Portaly Payment product: after someone buys, Portaly offers them their own referral link, and they earn a commission when someone else buys through it.
One switch, one rate, every eligible plan. Promotion is configured on the product, not per plan — a creator with ten one-time plans turns it on once and all ten pay the same rate. There is no per-plan rate; if a creator asks for one, say it is not available today and do not imply it is coming.
This skill is a switch plus attribution, not a commission engine. Portaly owns the promoter's link, the ledger, the settlement and the payout. The only thing that lands in the creator's own codebase is remembering which referral link a buyer arrived with, and passing it along when the checkout session is created.
Portaly Affiliate Environments
API host (overridable via PORTALY_API_HOST):
const PORTALY_API_HOST = process.env.PORTALY_API_HOST || 'https://portaly.ai'
| Aspect | Live mode | Test mode |
|---|---|---|
| API key prefix | pcs_live_ |
pcs_test_ |
| Real charges | Yes | No |
| Commission | Recorded and payable | Never created at all |
Promotion settings live on the product, and products are shared across modes — the switch you set with a test key is the same switch live buyers see. But a test-mode purchase writes to a sandbox order collection that the settlement chain does not read, so a test run never produces a real or a pretend commission. Say that out loud when you finish a test run; otherwise the creator will go looking for the money.
What This Skill Does Not Own
Never build any of these in the creator's project — they already exist, and a second implementation will disagree with the first:
- the promoter's referral link (Portaly issues it on its own purchase-complete page)
- commission amounts, the ledger, refund clawback, settlement timing
- the promoter's earnings dashboard and withdrawals (
https://rewards.portaly.cc)
Before You Switch Anything On
Two limits, both of which decide whether this can be switched on at all:
- One-time, fixed-price plans only. The switch covers the whole product, but only plans that are active, one-time, fixed-price and not free are actually included. Subscription plans cannot have promotion. Neither can dynamic-pricing plans — a promoter has to see a fixed price to know what a referral is worth.
- Taiwan accounts only. Payouts run through Taiwanese bank accounts, so the feature is limited to Taiwan-based Portaly accounts.
Payout rules are Portaly's to explain, not yours. A buyer gets their link with no signup and commission accrues against their email; the conditions for turning that into money are documented in Portaly Rewards, which is where the promoter goes to see it. Point there. Do not recite the requirements back to the creator, and never restate them from memory — they change, and a stale copy in the creator's chat log or on their site becomes their problem.
Quick Start
Precondition — a working Portaly Payment setup. You need an API key and at least one active one-time, fixed-price plan. If either is missing, do
portaly-paymentfirst (register athttps://portaly.cc/payment, create the key athttps://portaly.cc/admin/creator-subscription).
- Read
PORTALY_API_KEYfrom.env/process.env. Never ask the user to paste it into the chat, and check.gitignorecovers.env. - Prefer starting with a
pcs_test_*key. - It has to be a full-scope key. Turning promotion on and setting
promotionUrlare writes; an integration-scope key (pcs_test_itg_*/pcs_live_itg_*) reads fine but is refused with403 KEY_SCOPE_FORBIDDENon both. If that is all the project has, the merchant creates a full-access key athttps://portaly.cc/admin/creator-subscription— you cannot work around it. - Rate limits: reads 120/min, writes 20/min, per the rest of the creator-subscription API. This skill's writes are one call per plan (
promotionUrl) plus one for the switch, so a merchant with more than ~19 plans will run into429partway through. Batch below the limit, honourRetry-After, and re-readGET .../promotionafterwards to confirm every plan actually landed — a half-applied set leaves some referral links on product pages and the rest on the site root.
1.5 Report the installed skill version
Report this skill's version once per session so the merchant's dashboard can flag an outdated install. Single, non-blocking, no user data in the body.
This request always goes to https://portaly.ai, never to PORTALY_API_HOST. It carries the merchant's bearer API key, and PORTALY_API_HOST is just a line in the project's own .env — anyone who edits it could repoint a credentialed request at a host they control.
POST https://portaly.ai/api/creator-subscription/skill-version
Authorization: Bearer {PORTALY_API_KEY}
Content-Type: application/json
{ "skillName": "portaly-affiliate", "version": "0.1.0" }
version is this file's frontmatter version — use the literal value from the SKILL.md you're currently running. Ignore failures; it never blocks anything else.
- 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 }. Filter it against the actual project (drop anything already present locally — the server cannot see the project tree, only you can). Mention what survives once this session as something they could add, never as something they're missing. Discovery only — do not runnpx skills addyourself unless asked. - If more than one Portaly skill is installed, surface the list once per session total, not once per skill.
Workflow
1. Find the plans that qualify
GET {PORTALY_API_HOST}/api/creator-subscription/promotion
Authorization: Bearer {PORTALY_API_KEY}
→ { "data": { "enabled", "commissionRate", "serviceFeeRate",
"minCommissionRate", "maxCommissionRate", "defaultCommissionRate",
"plans": [ { "planId", "name", "amount", "promotionUrl",
"included", "commissionRate", "commissionAmount",
"excludedReason", "excludedMessage" } ] } }
One call answers both questions: what the current setting is, and which plans it does or would cover. Read plans[] and handle what you find:
- Some plans are eligible (
excludedReason: null) → list their name and amount, and say plainly that the switch and the rate cover all of them at once — there is no per-plan rate today. Before the switch has ever been turned on, an eligible plan still reportsincluded: false; that is expected, not a problem to report. - No plans at all → offer to create a one-time fixed-price plan first (hand off to
portaly-payment). - Everything excluded as
PROMOTION_BILLING_PERIOD_UNSUPPORTED→ say plainly: "Buyer promotion only works on one-time payment plans, so your subscription plans can't use it. If there's a single course or one-off product you want promoted, create a one-time plan for it and we can switch promotion on." Do not offer a timeline, a roadmap, or "coming soon" for subscription support. - Excluded as
PROMOTION_PRICING_TYPE_UNSUPPORTED→ this is the common case for sites that compute the price themselves. Say: "Promotion needs a fixed plan price so promoters can see what they earn per referral. Your plan takes its amount at checkout, so Portaly has no fixed number to show." Then offer the fix and do it with them: create one fixed-price one-time plan per product, and change their checkout call to send the matchingplanIdinstead of computing anamount. This is a simplification, not a rewrite — and it also gives them per-product orders and stats on Portaly's side. Do not promise dynamic-pricing support. - Excluded as
PROMOTION_URL_REQUIRED→ the plan has no landing page yet. Ask which page on their site sells it, and set it (step 2). - Excluded with
excludedReason: null→ the plan itself is fine and the product switch is simply off. This is what a first read returns for every eligible plan, and it is the only exclusion that carries no message. Go to step 4 and turn the switch on; do not report it as a plan problem, and do not quoteexcludedMessagehere — it isnull.
For any other excludedReason, excludedMessage is written for them and safe to quote.
2. Give each plan a landing page
A referral link has to open something, and Portaly hosts no public page for a Payment plan — so each plan needs a URL on the creator's own site.
plans[].promotionUrl in the read is the resolved value, not the plan's own field. Portaly falls back to the merchant's configured site URL (appBaseUrl), so a plan with nothing of its own still comes back with a URL and no PROMOTION_URL_REQUIRED — pointing every referral link at the site root. Do not treat a non-null promotionUrl as "already handled". A plan needs this step when either of these is true:
- it reports
excludedReason: "PROMOTION_URL_REQUIRED"(no plan URL and no usableappBaseUrl), or - its
promotionUrlequals the product's ownappBaseUrl— that is the fallback showing through, not a product page. Read that value once fromGET {PORTALY_API_HOST}/api/creator-subscription/config(data.appBaseUrl) and compare every plan against it
Do not try to spot the fallback by looking for plans that share a URL with each other. When only some plans fall back, their value differs from the ones that do have real pages, so the comparison comes out false exactly when it matters — and a product with a single plan has nothing to compare against at all.
Only a plan whose promotionUrl is distinctly its own is genuinely done. When in doubt, propose it in the table below and let the creator confirm; setting a URL that was already right costs one idempotent call.
Work the mapping out from their project first. Do not interview them plan by plan. You are running inside their codebase, and it already contains the answer: the code that creates checkout sessions has to pick a planId, so wherever that choice is made — a product constant, a CMS field, a database column, a route param — is also where the product's own page is defined. Read that, plus their route structure, and derive the mapping yourself.
Then propose the whole thing at once and ask for one confirmation:
"I found these three one-time plans and matched each to a page on your site. Say the word and I'll set them:
Plan Landing page AgentSkill 入門 ( plan_123)https://cabai.example/products/agentskillClaude Code 深度工程手冊 ( plan_456)https://cabai.example/products/handbook一對一諮詢 ( plan_789)❓ couldn't find a page — is there one?
Ask only about the rows you genuinely could not resolve, and get the base URL once rather than repeating it in every row.
It has to be their live production address. Portaly rejects http://, localhost, any IP address literal (public ones included, v4 and v6), any hostname with no dot, the reserved suffixes .local / .localhost / .internal / .test / .example / .invalid, a URL carrying credentials, and anything that is not a plain web page — the value becomes the target of a Portaly-hosted short link that promoters share publicly, so a dev address would send every visitor nowhere. You are working inside their repo, where localhost:3000 sits in every config file; do not let it become the answer. If you only know the dev URL, ask for the live one.
Then write them:
PUT {PORTALY_API_HOST}/api/creator-subscription/plans/{planId}
Authorization: Bearer {PORTALY_API_KEY}
Content-Type: application/json
{ "promotionUrl": "https://theirsite.example/products/agentskill" }
One call per plan, but the creator only answered once. Setting this does not turn promotion on — that is step 4.
If a plan genuinely has no page of its own, say so and leave it unset rather than pointing it at the home page: Portaly already falls back to the site root on its own, and a promoter whose link dumps visitors on a generic landing page converts badly. Better to tell the creator that plan needs a real page first.
3. Agree on the commission rate
One rate covers the whole product, so agree it once. Read the allowed range from the GET response above rather than hardcoding it, and work it out in the creator's own numbers, because a percentage means nothing on its own:
"You have three one-time plans: NT$1,500, NT$2,400 and NT$5,999. One rate applies to all of them — at {defaultCommissionRate}% a promoter earns about NT$225 on the cheapest and NT$900 on the dearest, and Portaly takes a {serviceFeeRate}% service fee. The allowed range is {minCommissionRate}–{maxCommissionRate}%. Courses and other high-margin, word-of-mouth products usually need the higher end to get anyone actually sharing. What do you want to use?"
If they ask for a different rate per plan, say it is not available today — one rate per product. Do not imply it is coming.
If they pick something outside the range, say what the range is and ask again — do not silently clamp it.
The rate must also be a whole number. commissionRate: 12.5 is refused with 400 PROMOTION_INVALID_REQUEST even though it sits inside the range, and that error's message covers both causes at once, so it will not tell you which one you hit. Round to an integer before sending, and tell the creator you did.
Take the per-sale figure from plans[].commissionAmount in the read-back rather than doing the arithmetic yourself — but note which rate it was computed at. Before the PUT it reflects the current or default rate, not the one you are proposing, so don't quote it against a rate the creator has not set yet.
Establish the base before any figure is published
plans[].commissionAmount is worked out at the plan's list price. Commission follows what the buyer actually paid — Portaly's purchase-complete page and the settlement both use the charged amount — so on a plan that a discount reaches, list price is a ceiling rather than the number to put on the creator's website. Check once:
GET {PORTALY_API_HOST}/api/creator-subscription/discount-codes?status=active
Authorization: Bearer {PORTALY_API_KEY}
status=active is a stored status, not "redeemable right now", and the list is paginated. Page through all of it (?limit=&startAfter=, following pagination.nextCursor until it is null) rather than reading the first page only, and ignore any code that cannot currently be used:
redeemByalready past, orredeemFromstill in the futuretimesRedeemedhas reachedmaxRedemptions
Re-basing against a code nobody can redeem understates the promoter's earnings just as badly as missing a live one overstates them. maxRedemptionsPerCustomer is not a reason to skip a code — it still applies to the next buyer.
For each plan, find the codes whose rules[] reach it — a rule with appliesTo.type: "specific" listing this planId, otherwise one with appliesTo.type: "all"; a specific rule wins over the all fallback. Then:
| What reaches the plan | The base to quote |
|---|---|
| Nothing | The list price. commissionAmount is exact — publish it as-is |
discount.type: "fixed" |
amount - discount.value |
discount.type: "percent" |
round(amount × (100 - discount.value) / 100) |
discount.type: "free" |
No figure. Those sales charge nothing, so they pay the promoter nothing — say that instead of quoting a number |
A signupRefCode discount applies with no code passed at checkout at all, so a plan carrying a ref-code rule is in the discounted case even when the creator believes they send nothing. Carry the base you land on into references/partner-program-copy.md as {計算基數}, alongside {金額} = round({計算基數} × commissionRate / 100) — the same formula the API uses for commissionAmount, so re-basing keeps the two numbers consistent. The published copy states both, so the sentence stays true when a buyer pays less. That copy labels it 成交金額 rather than 售價 on purpose: on a discounted plan the base is below the list price, and publishing it as 售價 would contradict the price on the creator's own product page.
4. Switch promotion on
PUT {PORTALY_API_HOST}/api/creator-subscription/promotion
Authorization: Bearer {PORTALY_API_KEY}
Content-Type: application/json
{ "enabled": true, "commissionRate": 20 }
- This decides who gets paid what. With a live key, restate which plans it will cover, the rate and the mode, and wait for an explicit yes before sending.
- A 200 does not mean anything went live. Eligibility here is billing period, pricing type, amount and status — it does not look at the landing page. So the call succeeds as long as one plan clears those, and a plan with nowhere usable to land still comes back
included: false. Readplans[]from the response and go byincluded, never by the status code. - Tell the creator exactly which plans went live and which did not — a partial result is normal here, not an error. If nothing is
included, treat it as a failure however clean the response looked: fix whatexcludedReasonnames (usually back to step 2) and switch on again before writing any attribution code. - On failure, stop — do not write any attribution code. A referral link handed out while the switch is off earns the promoter nothing. See
references/promotion-api.mdfor the error codes; three are worth knowing here —PROMOTION_LOCALE_UNSUPPORTED(not a Taiwan account: not something code can fix, they need Portaly support),PROMOTION_NO_ELIGIBLE_PLAN(back to step 1), andKEY_SCOPE_FORBIDDEN(an integration-scopepcs_*_itg_*key — this endpoint needs the merchant's own full-access key; reading is fine with either).
5. Capture the referral code on the creator's site
This is the only code that belongs in their project. State the rule in plain words first:
The last referral link someone clicked gets the commission, for 3 days. Click A's link, then B's, then buy → B earns it. Come back after 3 days → nobody does. This matches how Portaly's own store counts it, so the numbers agree.
The contract:
| URL parameter | ?ps=<code> |
| Cookie | portaly:profitSharing |
| Lifetime | 3 days, sameSite: 'Lax', path: '/', httpOnly, secure on an https origin only |
| Repeated visits | last one wins, and the 3 days restart |
| Same parameter twice in one URL | ignore it entirely |
Write the cookie server-side and httpOnly. The browser never needs to read it — only the server does, when it creates the checkout session — and a cookie the page can write is a cookie the buyer can write, which is the whole point of guardrail 5.
Every stack can do this, including the ones with no server rendering: the project already needs a server endpoint for checkout (the API key cannot ship to the browser), and every static/SPA host has a request hook that runs before the document — Next.js middleware, Vercel or Netlify edge middleware, a Cloudflare Worker. Reach for document.cookie only when there is genuinely no such hook, and then say out loud what it costs. See references/attribution.md for the implementations and that trade-off in full.
6. Attach it when creating the checkout session
const ps = (await cookies()).get('portaly:profitSharing')?.value
await fetch(`${PORTALY_API_HOST}/api/creator-subscription/checkout-sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PORTALY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
planId,
successRedirectUrl,
...(ps ? { profitSharingId: ps } : {}),
}),
})
Nothing has to reach Portaly's checkout page, and nothing should. That page runs on
portaly.ai — a different origin, where the creator's cookie is not readable and a forwarded query
parameter would mean nothing. The referral code travels server-to-server in this one call and
nowhere else, so a checkout URL with no ?ps= on it is the correct result, not a bug. Say this
out loud when handing the integration over: "carry the code through to checkout" is the natural
wrong guess, and someone will otherwise spend an afternoon on it.
Two rules that are not negotiable:
- Read the value server-side from the cookie. Never accept it from the request body, a query string, or
localStorage— if the browser can choose it, anyone can claim someone else's sale. - Omit the field when there's no cookie. It must be 1–64 characters, so an empty string is a
400and the checkout session is never created — that would fail the sale for the majority of buyers, who arrive with no referral cookie at all.
An unknown, expired or mismatched code is ignored and the checkout still completes. That is deliberate: losing the attribution is bad, losing the sale is worse.
7. Tell the creator what their buyers will see
They do not build anything for this. After paying, the buyer stays on Portaly's own purchase-complete page, which offers them their referral link and restates the rate and what they'd earn. The link is stable per purchase: the same order always yields the same link, so closing the tab and coming back is safe. A buyer who buys again gets a second, different link — both accrue against the same email, so their Rewards total covers all of them, and neither link stops working. Say it that way rather than "you always get the same link", or the creator will answer that support question wrongly.
Portaly also emails the buyer an invitation with a link back to that same page, so closing the tab is recoverable. Tell the creator this is happening, because it goes to their customer and they did not write it: it is sent by Portaly, from Portaly's address, with the sender name shown as {their brand name}(透過 Portaly).
Earnings, payout status and the payout rules all live at https://rewards.portaly.cc — give the creator that link and stop there.
8. Verify with a test key, spending nothing
- Confirm
PORTALY_API_KEYispcs_test_*. GET .../promotion— the switch is on, at the rate you expect, and every plan you meant to cover showsincluded: true.- Open
https://{their-site}/{their product page}?ps=test-codein a private window. In DevTools → Application → Cookies,portaly:profitSharingexists and expires in ~3 days. - Remove the parameter and reload — the cookie is still there. (This is what proves you persisted it rather than just reading the URL.)
- Start a checkout and check the server log for the outgoing session payload: log only whether
profitSharingIdwas present, never the API key or the whole body. - Complete payment with a TapPay test card, and confirm the promotion block appears on the completion page. On the 3DS return it can take a few seconds — the page shows the payment as successful before the order is finished being written, and the block waits for it rather than guessing. In test mode the block is deliberately read-only — it names the plan and the rate but shows no "get my link" button, because a test purchase must not mint a real referral link. That is the correct result, not a broken one.
- Say the quiet part: this run charged nothing and earned nothing. Test-mode purchases never produce commission, and never produce a referral link. Switch to
pcs_live_*for production; no code changes are needed, since the mode comes from the key.
Preferred Response Shape
- What qualifies and what doesn't, and why
- The decision the creator has to make (turn it on for the product, at what rate)
- The API call you're about to make and what it changes (with live/test stated)
- Files added or changed in their project, one line each
- The code itself, in their stack
- What their buyers will see, and the one link where earnings and payouts live
- Test-mode checklist with real pass/fail per item
Write for a creator who is not an engineer: what will happen, then how. Use their own product prices in every example.
Guardrails
- Never compute or pay a commission in the creator's shipped code. No
amount * rateat runtime, no earnings ledger, no "paid out" flag (re-basing one published figure for the creator's own page, per step 3, is fine — that number is copy, not accounting) — Portaly holds the only copy, and a second one will disagree and become a dispute the creator has to answer. - Never hardcode the rate, the range or the service fee. Read them from
GET .../promotion; a number frozen into the project keeps saying 15% long after the creator changed it. - Never put
PORTALY_API_KEYin client code.NEXT_PUBLIC_,VITE_,REACT_APP_prefixed variables are inlined into the browser bundle; putting the key there publishes it. - Never point a landing page at a dev address.
localhost, any IP literal (public ones too), anhttp://URL, or a reserved suffix like.test/.internal/.exampleis refused, and for good reason: promoters share these links with other people..testand.internalare the traps — they read like real staging domains. Full list inreferences/promotion-api.md. - Never trust an attribution code from the browser. Server-set
httpOnlycookie, read server-side; reject a repeated parameter; ignore anything malformed. - Never invent an earnings figure, and never publish a NT$ amount without its base. A promoter's accrued earnings come from Portaly or are not shown — link to
https://rewards.portaly.cc; a placeholder that ships is a number a promoter will try to reconcile. A per-sale figure is fine, but only next to the price and rate it came from (NT$225alone goes stale and becomes wrong the moment a discount applies; "售價 NT$1,500 的 15%,也就是 NT$225" stays true). Re-base it against active discount codes first — step 3. - Subscription and dynamic-pricing plans: state the limit, offer the alternative, promise nothing. No timelines, no roadmap, no "should be supported soon".
- Never hand out a referral link before the switch is confirmed on. Sales through it would earn the promoter nothing.
- Never restate or invent the payout rules. They are Portaly Rewards' to state and they change; link to
https://rewards.portaly.ccinstead of copying conditions into the chat, the creator's site, or their FAQ. - Never claim a test run earned anything. Test-mode purchases produce no commission, and the completion page issues no referral link for them.
- Never invent endpoints or fields. This skill uses exactly:
GET/PUT /api/creator-subscription/promotion,GET /api/creator-subscription/config(to readappBaseUrl),GET /api/creator-subscription/discount-codes(to re-base the figure, step 3),promotionUrlviaPUT /api/creator-subscription/plans/{planId},profitSharingIdon checkout-session creation, andPOST /api/creator-subscription/skill-versionto report the version. Nothing beyond that list. If something 404s, say the feature isn't enabled on their account and stop — don't smuggle attribution throughmetadata(the Python and Go callback adapters fail closed on custom metadata keys). - Turning promotion on with a live key needs an explicit yes, with the covered plans, the rate and the mode restated first.
- Never mass-message the creator's buyers for them. Portaly already emails each buyer their own invitation; anything beyond that — exporting a customer list, a broadcast — is the creator's to decide and theirs to do.
- Never interrogate the creator field by field. Derive what you can from their project, propose the whole mapping in one table, and ask once. A creator with ten plans must not be asked ten questions.
- Windows: run
chcp 65001(cmd) or set$OutputEncodingto UTF-8 (PowerShell) before commands carrying Chinese plan names, or they arrive as mojibake.
Deliverables
- which plans qualify, which don't, and the reason for each
- a plan → landing page table derived from their own project, confirmed in one pass rather than interrogated plan by plan
- the product promotion switch result, read back from Portaly, naming every plan it did and did not cover
- attribution wired into their actual stack: URL capture, cookie persistence, and the server-side hand-off at checkout-session creation
- a short, publishable explanation of the program for their own site, with every NT$ figure stated next to the price it was derived from, linking to Portaly Rewards for earnings and payout
- a test-mode checklist with real results, and the switch-to-live steps
Resources
references/promotion-api.md— full request/response fields and the error-code table for the two promotion endpoints. Read it before calling either one, or when you get a code you don't recognise.references/attribution.md— the cookie contract in full, with SSR, SPA and static-site implementations and the edge cases (repeated parameters, subdomains, Safari ITP). Read it when writing or debugging the capture code.references/partner-program-copy.md— ready-to-publish 正體中文 copy explaining the program to buyers. Read it when producing the creator's own page or announcement.scripts/check_promotion_setup.mjs— read-only preflight: prints mode, the product switch state, and which plans are included or excluded. Writes nothing, but it does call the API, so it needsPORTALY_API_KEYand network access. Run it before wiring code and again before going live.../portaly-payment/SKILL.md— creating plans, creating checkout sessions, verifying callbacks. This skill assumes that is already done.https://rewards.portaly.cc— where promoters see their earnings, withdraw, and read the payout rules. The only authoritative source for all three.https://portaly.ai/openapi.json— the live API contract. Check it before trusting any endpoint shape written here.