Portaly Payment Integration (Team / Integration-Scope)
Use this skill to help an engineer wire their application into Portaly Payment hosted checkout using an integration-scope API key. Keep answers operational: step lists, request/response bullets, copy-ready code — not architecture essays.
Role: What This Key Can And Cannot Do
The key you're working with is an integration-scope key (prefix pcs_test_itg_ or pcs_live_itg_), issued by the Portaly Payment merchant specifically for this integration. It is not the same as a full-access key.
This key can:
- read subscription plans
- create checkout sessions
- verify and consume signed callbacks
- (optionally) cancel/resume a subscription, create a subscriber portal session, and query subscriptions/orders
This key cannot:
- create or modify plans
- change merchant config (branding, logo)
- create or modify discount codes
- upload plan or merchant images
Any attempt at the above returns 403 KEY_SCOPE_FORBIDDEN. This is by design, not a bug to work around.
Plans, pricing, and discount codes are owned and managed by the merchant in the Portaly dashboard — always. They can change at any moment: a new plan added, a price changed, a plan taken down. Your integration must fetch plan data at runtime, every time, and never hardcode a plan name, amount, or discount code into source, config, or a build-time static page. If it's not coming from a live GET /api/creator-subscription/plans call, it's stale by definition.
Quick Start
Precondition — you need an integration key from the merchant. Unlike a solo integration where you'd register your own Portaly Payment account, here the merchant already has one. They create the integration key in their own dashboard and hand it to you together with their integration instructions (which callback URL to use, which plans apply, etc.). You do not sign up for anything yourself — if you don't have a key yet, ask the person who briefed you on this integration for one.
Ask for
PORTALY_API_KEY(apcs_test_itg_…orpcs_live_itg_…value) andPORTALY_CALLBACK_SECRET, together with whatever integration notes the merchant provided.Put both in
.env:PORTALY_API_KEY=pcs_test_itg_xxx # or pcs_live_itg_xxx for live mode PORTALY_CALLBACK_SECRET=xxxThe agent reads these at runtime via
process.env.PORTALY_API_KEY(Node) oros.environ["PORTALY_API_KEY"](Python) — never inline the literal value.Before proceeding, verify
.gitignoreincludes.env. If it doesn't exist or doesn't list.env, create/update it now. Never commit credentials.Never ask for the API key or
callbackSecretto be pasted into chat. If it happens anyway, tell the user to rotate the key in the Portaly dashboard — treat the pasted value as compromised.
1.5 Report the installed skill version
Report this skill's version once per session, the same way portaly-payment does, so the merchant's dashboard can flag an outdated install. Single, non-blocking, no user data in the body.
- Send when: right after install/update if
PORTALY_API_KEYis already set, otherwise on the first real Portaly API call this session. - If
PORTALY_API_KEYisn't set yet, skip for now — don't prompt the user just for this.
POST https://portaly.ai/api/creator-subscription/skill-version
Authorization: Bearer {PORTALY_API_KEY}
Content-Type: application/json
{ "skillName": "portaly-payment-integration", "version": "0.6.3" }
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 }. 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.
Workflow
1. Get the integration key and callback secret
- The merchant creates the integration key and gives it to you along with their briefing — you don't self-serve register.
- Store
PORTALY_API_KEY/PORTALY_CALLBACK_SECRETin.env(or the project's secret manager) per Quick Start above.
2. Fetch active plans at runtime
GET /api/creator-subscription/plans?status=activewithAuthorization: Bearer {PORTALY_API_KEY}.- Render each plan's
name,amount,billingPeriod,imageUrl. IflistPriceis present and higher thanamount, show it struck-through next toamountas the "was" price — it is display-only and never affects what's charged. - Only show a pay button for a plan whose
statusis"active". Never render, price, or discount-code anything you didn't just fetch — no hardcoded plan lists, no build-time snapshot. - See
references/api-contract.md→ "Read Subscription Plans" for full field list and example.
3. Create a checkout session
POST /api/creator-subscription/checkout-sessionswithplanId,callbackUrl(must be HTTPS), and optionallysuccessRedirectUrl/cancelRedirectUrl/metadata/discountCode(pass through a buyer-entered code verbatim — never generate or manage codes yourself).- Also optionally
profitSharingId— the referral code a buyer arrived with when the merchant runs buyer promotion. Read it server-side from your own cookie; never accept it from the browser's request body, or anyone can claim someone else's sale. Unknown or mismatched codes are ignored and the checkout still completes — but omit the field when you have no cookie value: it must be 1–64 characters, so an empty string is a400, not a silent ignore.
- Also optionally
- If your users are already signed in to your product, also send
customerEmail+customerName(pre-fills the checkout form) andemailVerified: true(drops the emailed verification code, because you already verified that email). Server-side only — the buyer-facing routes silently dropemailVerified(no error to handle), and it is ignored here without a non-blankcustomerEmail. - Redirect the buyer to the returned
data.checkoutUrl. Treat it as authoritative; never reconstruct it. - Persist
sessionId,checkoutToken,expiresAt. - See
references/api-contract.md→ "Session Creation" for the full request/response shape.
4. Verify and consume the signed callback
- Verify
x-portaly-signature(HMAC-SHA256, secret =callbackSecret) over{x-portaly-timestamp}.{stable_json(payload)}. - Reject callbacks whose
x-portaly-timestamp(an ISO datetime, not Unix seconds) is more than 5 minutes from now in either direction — too old is a stale/replayed delivery; too far in the future a forged or badly-skewed one. The symmetric ±5-minute window tolerates ordinary NTP drift; do not tighten the future side to "reject any future timestamp", which 401s legitimate callbacks (seereferences/callback-signature-v1.md→ Safe handler order). - Dedup on an event-specific key, not
sessionIdalone.subscriptionId === checkoutSessionId === sessionIdis the same value for every event on a subscription, so keying on it treatspayment.succeeded,cancel_requested, andcanceledas "already processed" and silently drops them. Build the key from the event type plus the subscription plus the event's own timestamp/id — e.g.`${x-portaly-event}:${subscriptionId}:${x-portaly-timestamp}`(or a per-delivery id if the payload carries one). Skip only when that composite key has already been processed. - Pick the adapter that matches the repo's runtime —
scripts/sign_callback.mjs(Node/TS),scripts/sign_callback.webcrypto.mjs(edge/WebCrypto runtimes withoutnode:crypto), orscripts/sign_callback.py(Python). Don't translate the signer from memory: the key ordering islocaleCompare, and a naive code-point/.sort()silently 401s real callbacks. For an unlisted runtime, use a documented server-side bridge or keep the receiver blocked until a native implementation passes the vectors — seereferences/callback-signature-v1.md. - Before shipping the receiver, run
scripts/check_callback_vectors.mjs --runtime <node|webcrypto|python|go>against the committed production-derived vectors (references/callback-signature-v1-vectors.json). Passing self-signed fixtures is not enough — sender and receiver can share the same ordering bug. - Handle
creator_subscription.checkout.failed, not just.completed. A declined first charge emits its own callback carryingsessionId,profileId,planId,planName,mode,amount,currency,customerEmail,failureReason,failedAt. It has nosubscriptionId(none was created), so dedup it onsessionId.test-mode sessions emit it too — checkmodebefore acting. If your endpoint was down, re-deliver withPOST /api/creator-subscription/checkout-sessions/{sessionId}/retry-callback; the subscription-keyed retry route cannot reach a failed first charge. - Handle refund terminal events even though this key cannot initiate them. Merchant/admin refunds emit
creator_subscription.payment.refundedor.refund_failed; deduplicate each onorderId, and reconcile withGET /api/creator-subscription/orders/{orderId}. A delayed TapPay refund can remain pending through up to three daily scheduled attempts, so a terminal outcome can take about three days from the202; keep polling until a terminal timestamp appears, and contact Portaly support only after terminal failure or that retry window passes without an outcome. A separatecreator_subscription.canceledevent has no ordering guarantee. Never callPOST /orders/{orderId}/refundwith this integration-scope key; it deliberately returns403 KEY_SCOPE_FORBIDDENand requires a live full-scope key. - See
references/api-contract.md→ "Signed Callback" for the event table and payload shapes.
5. Handle checkout errors
422 PLAN_INACTIVE— the plan was archived between page load and checkout. Show a friendly "no longer available" message, re-fetchGET /plans?status=active, and re-render. Don't retry the same call.404 PLAN_NOT_FOUND— theplanIddoesn't exist for this merchant (misconfigured on your side, or the plan was removed). Log it, then recover the same way asPLAN_INACTIVE: re-fetchGET /plans?status=active, re-render the current plan list, and prompt the user to pick an available plan. Never show the buyer a raw payment error, and don't retry the sameplanId.403 KEY_SCOPE_FORBIDDEN— you (or a library) called the refund endpoint or a plan/config/discount write endpoint with this key. Do not retry. Do not attempt a workaround or use a different key you might have lying around. Tell the user plainly: this key is for integration only; refunds require the merchant's live full-scope path, while plan, pricing, and discount-code changes go through the Portaly dashboard.- See
references/api-contract.md→ "Error responses" and "Out Of Scope For This Key" for the full table.
6. Subscriber self-service (optional)
If the integration needs subscription lifecycle management, these are available to an integration key:
POST /subscriptions/{id}/cancel/POST /subscriptions/{id}/resume— stop or restore future renewals (not a refund; current period stays active untilcancelEffectiveAt).POST /portal-sessions→ redirect the subscriber toportalUrlfor a hosted self-service page (view/cancel/resume/payment history). Server-to-server only — never expose the API key client-side.GET /subscriptions,GET /subscriptions/{id},GET /orders, andGET /orders/{orderId}for query and reconciliation. The single-order response includesrefundRequestedAt,refundedAt,refundFailedAt, andrefundFailureReason. For a payout period,GET /orders?startDate=&endDate=&status=paid,liquid— the dates filtercreatedAt, which for these orders is the payment time (createdAt === paidAt).- Never reconcile against a subscription's
amount— that is the frozen base price, not a payment record; a subscription with adiscountsnapshot is charged less. Money comes from the renewal callback'samountorGET /orders. - See
references/api-contract.md→ "Subscription Query And Lifecycle", "Portal Session", "Order Query".
7. Go live
- Once the test-mode integration (
pcs_test_itg_…) is verified end-to-end, ask the merchant for a live integration key (pcs_live_itg_…) and swapPORTALY_API_KEY. - No code changes needed — mode is derived entirely from the key.
Guardrails
- This is an integration-scope key, not a money-movement or management key. Never initiate a refund, create/update a plan, change merchant config, create/update/delete a discount code, or upload a plan/merchant image — all return
403 KEY_SCOPE_FORBIDDEN. Explain the boundary and stop there; don't retry, look for a bypass, or ask for another key. - Runtime fetch only. Plan names, prices,
listPrice, and discount codes must never be hardcoded in source, config files, or a build-time static page. Plans can be added, repriced, or archived by the merchant at any time — always read them live viaGET /plans. callbackUrlmust be HTTPS. Serving over plain HTTP exposes the signature and payload in transit.- Verify every callback's HMAC signature; reject any timestamp more than 5 minutes from now in either direction (symmetric skew window — don't special-case "any future timestamp"); dedup on an event-specific key (event type +
subscriptionId+ the event's timestamp/id), never onsessionIdalone — see Workflow step 4. - Windows encoding: run
chcp 65001(cmd) or$OutputEncoding = [System.Text.Encoding]::UTF8(PowerShell) before rendering non-ASCII plan names/descriptions, so they don't come out garbled. - Rate limiting: read endpoints (plans, sessions, subscriptions, orders) allow 120 req/min;
POST /checkout-sessionsandPOST /portal-sessionsare not rate limited; subscription cancel/resume allow 20 req/min. On429, honorRetry-After. - Do not derive subscription state from redirect success pages alone — they're UX only. The signed callback or a status query is the source of truth.
Resources
references/api-contract.mdIntegration-scope subset of the Portaly Payment API contract: auth, plan read, checkout session create/query, signed callback, subscriber self-service, order query, rate limits, and the explicit out-of-scope (403) endpoint list.references/callback-signature-v1.mdRuntime routing, the exact v1 signing contract, safe handler order, fail-closed boundaries, and diagnosis guidance.references/callback-signature-v1-vectors.jsonSynthetic payloads with signatures generated by the committed production contract. Verify against these instead of self-sign/self-verify fixtures.scripts/check_callback_vectors.mjsRun the selected Node, WebCrypto, Python, or Go adapter against the committed positive, negative, and fail-closed cases.scripts/sign_callback.mjsNode.js/TypeScript callback signing and verification reference (prefer for Node/Express/Next.js).scripts/sign_callback.pyPython adapter; fails closed for arbitrary metadata keys and unsupported numbers.scripts/sign_callback.webcrypto.mjsSame scheme, for edge / WebCrypto runtimes that can't importnode:crypto(Cloudflare/Vercel Edge, Deno, InsForge edge functions).scripts/verify_callback.goandscripts/verify_callback_test.goGo adapter plus its production-derived and fail-closed tests.