Skip to content

Quickstart

A working integration in five steps. Time: ~5 minutes assuming you have your app credentials.

Each uncle-z product is a “app” on the gateway with three secrets, generated by the operator at provisioning time:

public_key: pk_<24 hex chars> # non-secret app id; sent on every request
secret: <64 hex chars> # request-signing secret
webhook_secret: <64 hex chars> # the gateway uses this to sign inbound webhooks; you verify with it

You’ll typically have two apps per product: one for mode=sandbox (development) and one for mode=live (production). See Modes for why.

If you don’t have credentials yet, ask the operator to run:

Terminal window
docker exec payment-server-1 /app/admin apps create \
--name <product-slug>-sandbox \
--webhook-url https://<your-product>.uncle-z.com/api/payment-webhook \
--mode sandbox

Every request to the gateway carries three headers:

HeaderValue
X-PAY-KeyYour public_key
X-PAY-TimestampCurrent Unix epoch seconds
X-PAY-SignatureHex HMAC-SHA256(secret, "<ts>.<METHOD>.<URL.Path>.<sha256(body) as hex>")

Reference signer in Node:

import { createHmac, createHash } from 'node:crypto';
function sign({ method, path, body, timestamp, secret }) {
const bodyHash = createHash('sha256').update(body || '').digest('hex');
const canonical = `${timestamp}.${method}.${path}.${bodyHash}`;
return createHmac('sha256', secret).update(canonical).digest('hex');
}

In Go:

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
func sign(secret, method, path string, body []byte, ts int64) string {
bodyHash := sha256.Sum256(body)
canonical := fmt.Sprintf("%d.%s.%s.%x", ts, method, path, bodyHash[:])
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(canonical))
return hex.EncodeToString(mac.Sum(nil))
}

In PHP:

function sign(string $secret, string $method, string $path, string $body, int $ts): string {
$bodyHash = hash('sha256', $body);
$canonical = "$ts.$method.$path.$bodyHash";
return hash_hmac('sha256', $canonical, $secret);
}
Terminal window
PK=pk_xxx # your public key
SK=xxx # your secret
BODY='{"external_user_id":"u-1","email":"buyer@example.com","amount_minor":2900,"currency":"USD","method":"polar","metadata":{"polar_product_id":"<polar-product-uuid>"},"return_url":"https://yourproduct.uncle-z.com/payment/return","invoice_prefix":"PROD-PRO"}'
TS=$(date +%s)
H=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
S=$(printf '%s' "$TS.POST./v1/payments.$H" | openssl dgst -sha256 -hmac "$SK" -hex | awk '{print $NF}')
curl -sL -X POST https://payment.uncle-z.com/v1/payments \
-H "X-PAY-Key: $PK" \
-H "X-PAY-Timestamp: $TS" \
-H "X-PAY-Signature: $S" \
-H "Content-Type: application/json" \
-d "$BODY"

Response:

{
"payment_id": "abc123…",
"status": "initiated",
"checkout_url": "https://sandbox.polar.sh/checkout/polar_c_…",
"invoice_number": "PROD-PRO-000042"
}

Pop it open in a new tab, embed it in an iframe, or hand it to a desktop app’s webview — whatever fits your UX. The buyer pays through the PSP’s hosted UI; the PSP redirects back to your return_url when done.

Don’t trust the redirect’s query params for status — the PSP may also send a webhook before the buyer gets back, and the redirect is best-effort. Use one of:

  • Webhook (preferred) — implement step 5; you’ll get payment.succeeded within seconds.
  • PollingGET /v1/payments/{id} until status is terminal.

The gateway POSTs JSON to your app’s webhook_url with two headers:

HeaderValue
X-PAY-TimestampUnix seconds at delivery time
X-PAY-SignatureHex HMAC-SHA256(webhook_secret, "<ts>.<rawBody>")

Note: this is the body verbatim, not the canonical request format from step 2. Webhook signing is simpler.

Reference verifier in Node:

import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(secret, ts, rawBody, signature) {
const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Make your handler idempotent — the gateway may deliver the same event twice if your endpoint times out and retries. Dedupe on payment_id + event (not on the random delivery id).

That’s the full integration. Detailed per-PSP guides linked in the sidebar; reference docs cover every endpoint, header, and error code.