Use when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout sessions, payment status reads, refunds, subscriptions, or handling UniwebPay webhooks with @uniwebpay/sdk in a PinMe project.
Use when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout sessions, payment status reads, refunds, subscriptions, or handling UniwebPay webhooks with @uniwebpay/sdk in a PinMe project.
PinMe UniwebPay Payment Integration
Guides writing payment services in a PinMe Worker (Cloudflare Worker TypeScript) that call UniwebPay directly through @uniwebpay/sdk.
Core model: PinMe provisions the UniwebPay wallet and keys per PinMe user (not per project) and injects UNIWEB_* environment bindings at Worker deploy time; Worker code calls UniwebPay directly with the SDK — it does not go through PinMe payment proxy routes, and it must not call the legacy VibeCash APIs.
Environment Binding Contract
exportinterfaceEnv {
UNIWEB_SECRET: string; // PinMe-provisioned sk_server_ key (server-side only)UNIWEB_WEBHOOK_SECRET?: string; // wallet-level whsec_, used to verify webhook signaturesUNIWEB_API_URL?: string; // UniwebPay API endpoint override (default https://apiskill.uniwebpay.com)UNIWEB_PAY_URL?: string; // UniwebPay checkout host override (default https://skill.uniwebpay.com)UNIWEB_WALLET_ID?: string; // user-level wallet id (wal_), diagnostics/reconciliation onlyWORKER_URL?: string; // this project's public URL: https://{projectName}.{platform api domain}PROJECT_NAME?: string; // PinMe project nameDB?: D1Database; // project D1 (if enabled)
}
Injection rules (metadata is rebuilt server-side by PinMe at deploy time; client-supplied bindings are ignored):
The UNIWEB_* bindings are injected only after the user's UniwebPay credentials have been provisioned. Newly created projects are provisioned automatically and get them immediately; existing projects must be redeployed after enabling UniwebPay or rotating keys to pick up new bindings.
WORKER_URL, PROJECT_NAME, API_KEY, DB and other base bindings are injected on every deploy, independent of UniwebPay.
All projects owned by the same PinMe user share one wallet, one sk_server_, and one whsec_.
PinMe never gives the full wallet secret (sk_live_) to a Worker. Do not ask the user for it, and do not put it in code, wrangler.toml, .dev.vars, responses, logs, D1, or frontend bundles.
If UNIWEB_SECRET is missing at runtime, the user has not enabled UniwebPay or has not redeployed — tell the user to enable it and redeploy; never fabricate a value.
SDK Client
Always instantiate on the server side (the Worker); the SDK throws when run in a browser:
The constructor's first positional argument is the key (must have an sk_server_ or sk_live_ prefix); the second is optional options: { baseUrl?, payUrl?, timeout? (default 30s), maxRetries? (default 2) }.
The SDK auto-retries only GET/DELETE on 429/5xx; POST/PATCH are never retried (avoids duplicate charges).
Install @uniwebpay/sdk only when Worker code imports it; pick the package manager from the project's existing lockfile.
Choosing an Integration Path
Scenario
Approach
Returns
Fixed-amount one-time collection
uniweb.links.create(...)
Permanent, reusable /p/ link (one-time payments only)
Stable product catalog
products.create + prices.create once, store the priceId
Price carries a permanent paymentUrl (/buy/ link)
Dynamic cart/order
Reuse or create a price, then uniweb.checkout.create(...)
session.url — one-time, expires in 24 hours
Subscriptions
Recurring price + checkout.create({ mode: "subscription" }) or subscriptions.create
Same as above
Server-side payment status checks
payments.get / list
Server routes only
Amounts are always integer minor units (cents). Default currency convention is SGD unless the app has a stronger existing convention. Do not create a new product/price on every page view — create stable catalog items once and persist the priceId.
The QR methods (wechat/alipay/paynow) all support SGD only — never generate "CNY via WeChat/Alipay" code.
Subscriptions (recurring / mode: "subscription") use card only.
When paymentMethodTypes is omitted, the server picks sensible defaults for the currency; when passed explicitly, validate user input against the table above first.
SDK Surface Quick Reference
The surface below is verified against source. All parameter fields are camelCase (priceId, webhookUrl, startingAfter, …); the SDK handles wire-level conversion itself. list() returns { data: T[], hasMore: boolean }; listAll() is an async generator available on products, prices, payments, customers, subscriptions, and links (not on checkout or refunds).
Products (webhookUrl is the per-product callback override):
Prices (the returned price carries a permanent paymentUrl; deactivate takes it off sale):
await uniweb.prices.create({
productId,
amount, // integer minor units
currency, // e.g. "SGD"type, // "one_time" | "recurring"
interval?, // "day" | "week" | "month" | "year"; recurring only
intervalCount?,
trialPeriodDays?,
metadata?,
});
await uniweb.prices.list({ productId?, limit?, startingAfter? });
await uniweb.prices.get(priceId);
await uniweb.prices.update(priceId, { active });
await uniweb.prices.activate(priceId);
await uniweb.prices.deactivate(priceId);
forawait (const price of uniweb.prices.listAll({ productId? })) {}
Checkout sessions (do not accept webhookUrl — events resolve through the price → product → wallet chain; the URL is one-time and expires after 24 hours):
Subscriptions (states include trialing / active / past_due / unpaid / canceled; update access only from verified webhooks or a trusted server-side reconciliation job):
Ordinary project routes must not call webhooks.set / remove / rollSecret or wallet.update — they mutate the wallet callback fallback and signing secret shared across all of the user's projects. Generate them only when the user explicitly asks for wallet administration and the route has project/admin-level authorization. Same for refunds, subscription mutations, payouts, KYC, and bank account APIs: generate only when the user explicitly requests that business flow and the code has validation, persistence, and authorization.
Webhook Integration
Callback URL: set it on the link/product, pointing at this Worker
Event delivery precedence: per-link webhookUrl > per-product webhookUrl > wallet-level fallback. The signing secret is always the wallet-level whsec_ (i.e. env.UNIWEB_WEBHOOK_SECRET).
PinMe sets a managed fallback callback URL on the wallet, but it exists only to obtain and preserve the signing secret — PinMe's server discards events it receives there (204); it never forwards them to the Worker. Business events must therefore set this project's webhookUrl explicitly on the resource that creates the payment:
Payment links: pass webhookUrl on links.create.
Checkout sessions: checkout.create has no webhookUrl field; events route through the price's product — set webhookUrl on products.create (or on the reused product).
Rules for building the webhookUrl:
Keep the callback path in a single constant (e.g. const WEBHOOK_PATH = "/api/pay/webhook") shared by the router and the webhookUrl construction, so a path mismatch can't 404 the callbacks and leave orders stuck in pending.
Use env.WORKER_URL as the base: new URL(WEBHOOK_PATH, env.WORKER_URL).toString(). It is the only public address available at runtime (the platform subdomain); the user's custom domain is not in env. Prefer it over request.url (the current request's host is not necessarily the deployed address), and non-HTTP contexts (cron/queue) have no request at all — fail loudly if it's missing rather than emitting a broken URL.
Local dev has no WORKER_URL; a request.url fallback resolves to localhost, which UniwebPay cannot reach. To test webhooks locally, expose the Worker through a tunnel (cloudflared / ngrok).
webhookUrl must be HTTPS. Put no secrets or trust-bearing data in the URL query — carry correlation like orderId in metadata, and verify identity from the signature plus metadata (a query string can be forged).
verifyWebhook(rawBody, signature, secret) returns Promise<WebhookEvent>; signature timestamp tolerance is ±5 minutes; it throws on failure.
Event shape: { id: "evt_...", type, created, data: { object, productId?, priceId?, productName? } }. The business object is in event.data.object; correlation like orderId is in event.data.object.metadata.
The webhook route must bypass the project's own auth. UniwebPay callbacks carry only uniweb-Signature (plus uniweb-Event-Id / uniweb-Timestamp), never the project API_KEY. If a global auth guard wraps all routes, exempt WEBHOOK_PATH — trust comes solely from signature verification. Otherwise callbacks get 401/403 and orders never fulfill.
Return 400 for invalid signatures (so UniwebPay stops pointless retries); return 500 for temporary processing failures (so it retries).
Enforce idempotency with event.id (combined with payment id / checkout session id / local order id).
Before fulfillment, verify amount, currency, metadata, and current order state.
Respond within 10 seconds; delivery does not follow redirects, so do not put the webhook route behind a redirect. The first delivery is immediate; on failure, retries follow at 5-minute, 30-minute, 2-hour, and 12-hour intervals, up to 6 attempts before the event is marked failed.
Keep UNIWEB_WEBHOOK_SECRET optional in TypeScript — a project can exist before provisioning or redeploy; return 501 with a hint when it's missing at runtime.
Security Rules
No Uniweb secret (UNIWEB_SECRET, UNIWEB_WEBHOOK_SECRET) may appear in responses, logs, metadata, test snapshots, D1, source code, committed wrangler.toml / .dev.vars, or browser code. For local dev, use an uncommitted .dev.vars only.
Do not import the SDK in browser-side code; do not use process.env in Cloudflare Workers — use the env argument.
Do not call legacy VibeCash APIs or PinMe payment proxy routes; do not call PinMe payment APIs with X-API-Key for UniwebPay collection — the Worker calls UniwebPay directly through the SDK.
successUrl / cancelUrl are UX redirects only. Never fulfill based on a browser redirect; grant access only after verified webhook processing (or another explicit server-side verification).
Validate user input before SDK calls: amount (integer minor units), currency (ISO 4217 and within the payment-method constraints), quantity, product/price IDs, payment method types, order ownership, and metadata shape.
Persistence Guidance (D1)
Add tables/migrations only when the project already uses D1 or the user asks for persistence. For order flows, store at least: local order id, the corresponding Uniweb link/session/payment/subscription id, amount and currency, status, timestamps, and processed webhook event ids (for idempotency). Never store secrets.
CREATE TABLE IF NOTEXISTS orders (
order_id TEXT PRIMARY KEY,
checkout_session_id TEXT UNIQUE,
status TEXT NOT NULLDEFAULT'pending',
amount_cents INTEGERNOT NULL,
currency TEXT NOT NULL,
paid_at INTEGER,
created_at INTEGERNOT NULL,
updated_at INTEGER
);
CREATE TABLE IF NOTEXISTS payment_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
order_id TEXT,
raw_payload TEXT NOT NULL,
created_at INTEGERNOT NULL
);
Webhook route blocked by the project's API_KEY / bearer auth guard
Callbacks get 401/403 and orders stay pending forever. Exempt WEBHOOK_PATH; trust comes solely from signature verification
Forgetting to set webhookUrl on the link/product
Events fall through to PinMe's managed fallback and are discarded; the Worker never sees them. Business events must point per-link/per-product at this Worker
Passing webhookUrl to checkout.create
The field does not exist. Set it on the backing product
Fulfilling on a successUrl redirect
Forgeable. Fulfill only from verified webhooks or server-side verification
Hardcoding the callback host or relying only on request.url
Build from env.WORKER_URL; request.url is a fallback only
Pairing CNY with wechat/alipay
Source limits QR methods to SGD only; CNY can only use card
Creating a new product/price on every request
Create stable catalog items once, persist and reuse the priceId
Calling webhooks.set/remove/rollSecret or wallet.update in ordinary business flows
Mutates/rotates the wallet callback and whsec_ shared by ALL of the user's projects. Generate only for explicit wallet-administration requests with admin authorization
Using floating-point major units for amounts
Always integer minor units
Using process.env in the Worker or importing the SDK in the browser
Use the env argument; the SDK rejects browser environments
Putting UNIWEB_SECRET / whsec_ in wrangler.toml, source, logs, or D1
PinMe injects them at deploy time; locally use an uncommitted .dev.vars only
Reading the body more than once, or as JSON, before verification
Read with request.text() exactly once and pass the raw string to verifyWebhook
Finish Checklist
@uniwebpay/sdk is installed only when Worker code imports it; package manager follows the lockfile.
Env includes the PinMe-injected bindings the code uses; UNIWEB_WEBHOOK_SECRET / WORKER_URL stay optional and their absence is handled.
The client is instantiated with new Uniweb(env.UNIWEB_SECRET, { baseUrl: env.UNIWEB_API_URL, payUrl: env.UNIWEB_PAY_URL }).
No VibeCash or PinMe payment proxy routes are used; no secret appears in source, responses, logs, D1, tests, or docs.
Amounts are validated integer minor units; payment methods and currencies follow the constraint table (QR methods SGD only).
Every payment creation that needs events carries a per-link/per-product webhookUrl built from env.WORKER_URL.
Webhook: raw body read exactly once, verifyWebhook verification, correct 400/500 semantics, event.id idempotency, route bypasses project auth, responds within 10 seconds.
Fulfillment does not rely on browser redirects; amount, currency, metadata, and order state are verified before granting access.
// webhookUrl construction so the address sent to UniwebPay always matches the
// route the Worker actually serves.
const
WEBHOOK_PATH
"/api/pay/webhook"
function
uniwebClient
env: Env
Uniweb
return
new
Uniweb
UNIWEB_SECRET
baseUrl
UNIWEB_API_URL
payUrl
UNIWEB_PAY_URL
function
json
data: unknown, init: ResponseInit = {}
Response
return
Response
json
function
assertAmountCents
value: unknown
number
const
Number
if
Number
isInteger
10
throw
new
Error
"amountCents must be an integer minor-unit amount >= 10"
return
function
normalizeCurrency
value: unknown
string
const
String
"SGD"
toUpperCase
if
/^[A-Z]{3}$/
test
throw
new
Error
"currency must be an ISO 4217 code"
return
// QR methods (wechat/alipay/paynow) support SGD only; card supports 10 currencies.
function
defaultPaymentMethods
currency: string
PaymentMethod
if
"SGD"
return
"card"
"wechat"
"alipay"
"paynow"
if
CARD_CURRENCIES
has
return
"card"
throw
new
Error
`unsupported currency: ${currency}`
function
normalizePaymentMethods
value: unknown, currency: string
PaymentMethod
const
Array
isArray
length
0
defaultPaymentMethods
const
map
(m) =>
String
toLowerCase
as
PaymentMethod
for
const
of
if
VALID_PAYMENT_METHODS
has
throw
new
Error
"paymentMethodTypes contains an unsupported method"
if
"card"
"SGD"
throw
new
Error
`${method} only supports SGD payments`
if
"card"
CARD_CURRENCIES
has
throw
new
Error
`card does not support ${currency}`
return
Array
from
new
Set
// Prefer the PinMe-injected WORKER_URL (the project's platform subdomain, the
// only public address available at runtime). request.url is only a fallback for
// older deploys; cron/queue contexts have no request, so WORKER_URL is required.
function
projectWebhookUrl
env: Env, request?: Request
string
const
WORKER_URL
url
if
throw
new
Error
"WORKER_URL binding is missing"
return
new
URL
WEBHOOK_PATH
toString
// ---- One-time collection: payment link ----
async
function
createPaymentLink
request: Request, env: Env
Promise
Response
if
method
"POST"
return
json
error
"method not allowed"
status
405
let
input
any
try
await
json
catch
return
json
error
"invalid JSON body"
status
400
let
amount
number
currency
string
paymentMethodTypes
PaymentMethod
try
assertAmountCents
amountCents
normalizeCurrency
currency
normalizePaymentMethods
paymentMethodTypes
catch
return
json
error
as
Error
message
status
400
const
orderId
randomUUID
const
uniwebClient
try
const
await
links
create
name
name
"Payment"
description
description
successUrl
successUrl
cancelUrl
cancelUrl
webhookUrl
projectWebhookUrl
metadata
projectName
PROJECT_NAME
return
json
linkId
id
url
url
catch
return
json
error
"failed to create payment link"
status
502
// ---- Dynamic orders: checkout session ----
// Note: checkout.create has no webhookUrl; the callback is set on the product.
// Stable catalog items should create the product/price once and persist the
// priceId — do not create new ones on every request.
async
function
createCheckoutSession
request: Request, env: Env
Promise
Response
if
method
"POST"
return
json
error
"method not allowed"
status
405
let
input
any
try
await
json
catch
return
json
error
"invalid JSON body"
status
400
let
amount
number
currency
string
paymentMethodTypes
PaymentMethod
try
assertAmountCents
amountCents
normalizeCurrency
currency
normalizePaymentMethods
paymentMethodTypes
catch
return
json
error
as
Error
message
status
400
const
orderId
randomUUID
const
Math
max
1
Math
floor
Number
quantity
1
const
uniwebClient
try
const
await
products
create
name
productName
webhookUrl
projectWebhookUrl
metadata
projectName
PROJECT_NAME
const
await
prices
create
productId
id
type
"one_time"
metadata
projectName
PROJECT_NAME
const
await
checkout
create
mode
"payment"
lineItems
priceId
id
successUrl
successUrl
cancelUrl
cancelUrl
customerEmail
customerEmail
metadata
projectName
PROJECT_NAME
if
DB
await
DB
prepare
`INSERT INTO orders(order_id, checkout_session_id, status, amount_cents, currency, created_at)
VALUES (?, ?, 'pending', ?, ?, ?)
ON CONFLICT(order_id) DO UPDATE SET checkout_session_id = excluded.checkout_session_id`
bind
id
Math
floor
Date
now
1000
run
return
json
checkoutSessionId
id
url
url
catch
return
json
error
"failed to create checkout session"
status
502
// ---- Webhook handling ----
async
function
handleUniwebWebhook
request: Request, env: Env
Promise
Response
if
method
"POST"
return
json
error
"method not allowed"
status
405
if
UNIWEB_WEBHOOK_SECRET
return
json
error
"webhook verification is not configured"
status
501
const
await
text
let
event
Awaited
ReturnType
typeof
try
await
verifyWebhook
headers
get
"uniweb-Signature"
""
UNIWEB_WEBHOOK_SECRET
catch
return
json
error
"invalid signature"
status
400
try
if
DB
const
object
data
object
as
metadata
Record
string
unknown
const
String
object
metadata
orderId
""
const
Math
floor
Date
now
1000
// event.id idempotency: duplicate deliveries become no-ops
await
DB
prepare
`INSERT INTO payment_events(event_id, event_type, order_id, raw_payload, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING`
bind
id
type
run
if
type
"payment.succeeded"
// Production code should verify amount/currency/order state here before fulfilling
await
DB
prepare
`UPDATE orders SET status = 'paid', paid_at = ?, updated_at = ? WHERE order_id = ? AND status != 'paid'`
bind
run
return
json
ok
true
catch
// 500 makes UniwebPay retry on the 5m/30m/2h/12h schedule
return
json
error
"processing error"
status
500
// ---- Router ----
export
default
async
fetch
request
Request
env
Env
Promise
Response
const
new
URL
url
// The webhook must come before any project auth guard: callbacks carry only
// uniweb-Signature, never the project API_KEY.
if
pathname
WEBHOOK_PATH
return
handleUniwebWebhook
// The project's own auth guard goes after this point, on the business routes.