SKN Pay — Payments API

v1 · Last updated 2026-05-08 · Stripe-flavoured wire format

Try the API in your browser

sknpay-test-integrator.fly.dev — interactive playground covering 7 scenarios (standard / 3DS / decline / cancel / idempotency / refund). Run a scenario, see the request and response, watch the signed webhook arrive — no code needed.

Using Claude Code, Cursor, or another AI assistant?

/docs/sknpay-api.md — same content as this page in markdown. Drop into your project and reference from AGENTS.md / CLAUDE.md.

The Payments API lets your application create hosted payment links, retrieve their state, issue refunds, and receive signed webhook events when a payment changes status. Cards never touch your servers — customers enter card details on the SKN Pay hosted page (PCI SAQ A scope).

Quickstart

End-to-end worked examples in three languages. Each one creates a payment, redirects the customer, then receives + verifies the signed webhook. Pick your stack and copy.

Node.js

// server.js — Node 20+, no dependencies
import { createHmac, timingSafeEqual } from 'crypto'
import express from 'express'

const SKNPAY = 'https://app.sknpay.com/api/v1'
const API_KEY = process.env.SKNPAY_API_KEY      // sk_test_… or sk_live_…
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET // whsec_…

const app = express()

// 1. Create a payment when the customer hits checkout.
app.post('/checkout', express.json(), async (req, res) => {
  const r = await fetch(`${SKNPAY}/payments`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': req.body.order_id, // race-safe re-tries
    },
    body: JSON.stringify({
      amount: req.body.amount_cents,
      currency: 'USD',
      description: `Order #${req.body.order_id}`,
      customer_email: req.body.email,
      success_url: `https://yoursite.com/orders/${req.body.order_id}/thanks`,
      cancel_url:  `https://yoursite.com/orders/${req.body.order_id}`,
      metadata: { order_id: req.body.order_id },
    }),
  })
  const payment = await r.json()
  // 2. Send the customer to the hosted page.
  res.redirect(303, payment.url)
})

// 3. Receive the signed webhook. raw() so we can verify the body byte-exact.
app.post('/webhooks/sknpay', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.header('SknPay-Signature')
  if (!verify(req.body.toString('utf8'), sig, WEBHOOK_SECRET)) {
    return res.status(400).send('bad signature')
  }
  const event = JSON.parse(req.body.toString('utf8'))
  if (event.type === 'payment.succeeded') {
    const orderId = event.data.object.metadata?.order_id
    // mark order paid, send fulfilment email, etc.
  }
  res.status(200).send('ok') // 2xx within 10s or we retry
})

function verify(rawBody, header, secret) {
  const m = header.match(/^t=(\d+),v1=([0-9a-f]{64})$/)
  if (!m) return false
  const ageSec = Math.floor(Date.now() / 1000) - parseInt(m[1], 10)
  if (ageSec < 0 || ageSec > 300) return false
  const expected = createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest('hex')
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(m[2], 'hex'))
}

app.listen(3000)

That’s the whole loop. Configure the webhook endpoint (and grab the whsec_) under Settings → Webhooksin the SKN Pay dashboard.

PHP

Plain PHP 8+ with cURL. Works as standalone scripts, or drop the relevant pieces into a Laravel controller / WordPress action / Slim route.

<?php
// create-payment.php — call this from your checkout button.
$apiKey  = getenv('SKNPAY_API_KEY');         // sk_test_… or sk_live_…
$orderId = $_POST['order_id'] ?? '';
$amount  = (int) $_POST['amount_cents'];
$email   = $_POST['email'] ?? '';

$ch = curl_init('https://app.sknpay.com/api/v1/payments');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: ' . $orderId,        // race-safe re-tries
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'amount'         => $amount,
        'currency'       => 'USD',
        'description'    => "Order #$orderId",
        'customer_email' => $email,
        'success_url'    => "https://yoursite.com/orders/$orderId/thanks",
        'cancel_url'     => "https://yoursite.com/orders/$orderId",
        'metadata'       => ['order_id' => $orderId],
    ]),
]);
$payment = json_decode(curl_exec($ch), true);
curl_close($ch);

// Send the customer to the hosted page.
header('Location: ' . $payment['url'], true, 303);
exit;
?>

<?php
// webhook-receiver.php — point your webhook endpoint here.
$secret  = getenv('WEBHOOK_SECRET');                    // whsec_…
$rawBody = file_get_contents('php://input');            // CRITICAL: raw, not parsed
$header  = $_SERVER['HTTP_SKNPAY_SIGNATURE'] ?? '';

if (!verifySignature($rawBody, $header, $secret)) {
    http_response_code(400);
    exit('bad signature');
}

$event = json_decode($rawBody, true);
if ($event['type'] === 'payment.succeeded') {
    $orderId = $event['data']['object']['metadata']['order_id'] ?? null;
    // mark order paid, send fulfilment, etc.
    // IMPORTANT: same event['id'] may arrive more than once — dedupe on it
}
http_response_code(200);
echo 'ok';                                              // 2xx within 10s

function verifySignature(string $body, string $header, string $secret): bool {
    if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) return false;
    $t   = (int) $m[1];
    $age = time() - $t;
    if ($age < 0 || $age > 300) return false;           // 5-min replay window
    $expected = hash_hmac('sha256', "$t.$body", $secret);
    return hash_equals($expected, $m[2]);               // constant-time compare
}

Frameworks: in Laravel, replace $_SERVER[...] with $request->header('SknPay-Signature') and file_get_contents('php://input') with $request->getContent(). In WordPress, hook a custom REST endpoint via register_rest_route() and read the raw body via $request->get_body().

Python

Flask example — same pattern works in Django (use request.body) or FastAPI (use await request.body()).

# server.py — Flask 3+, requires: pip install flask requests
import os, hmac, hashlib, time, json, re, requests
from flask import Flask, request, redirect, abort

SKNPAY_BASE    = 'https://app.sknpay.com/api/v1'
API_KEY        = os.environ['SKNPAY_API_KEY']        # sk_test_… or sk_live_…
WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']        # whsec_…

app = Flask(__name__)


@app.post('/checkout')
def checkout():
    """Customer hits this after clicking 'Pay'. We create a payment via the
    SKN Pay API and redirect to the returned hosted URL."""
    order_id = request.form['order_id']
    r = requests.post(
        f'{SKNPAY_BASE}/payments',
        headers={
            'Authorization':   f'Bearer {API_KEY}',
            'Content-Type':    'application/json',
            'Idempotency-Key': order_id,             # race-safe re-tries
        },
        json={
            'amount':         int(request.form['amount_cents']),
            'currency':       'USD',
            'description':    f'Order #{order_id}',
            'customer_email': request.form['email'],
            'success_url':    f'https://yoursite.com/orders/{order_id}/thanks',
            'cancel_url':     f'https://yoursite.com/orders/{order_id}',
            'metadata':       {'order_id': order_id},
        },
        timeout=10,
    )
    r.raise_for_status()
    return redirect(r.json()['url'], code=303)


@app.post('/webhooks/sknpay')
def webhook():
    """Signed webhook receiver. CRITICAL: read raw bytes, not parsed JSON,
    or the signature won't verify."""
    raw = request.get_data()                         # bytes, byte-exact
    sig = request.headers.get('SknPay-Signature', '')
    if not verify(raw.decode('utf-8'), sig, WEBHOOK_SECRET):
        abort(400, 'bad signature')

    event = json.loads(raw)
    if event['type'] == 'payment.succeeded':
        order_id = event['data']['object'].get('metadata', {}).get('order_id')
        # mark order paid, send fulfilment, etc.
        # IMPORTANT: dedupe on event['id'] — same id may arrive more than once
    return 'ok', 200                                 # 2xx within 10s


def verify(body: str, header: str, secret: str) -> bool:
    m = re.match(r'^t=(\d+),v1=([0-9a-f]{64})$', header)
    if not m:
        return False
    t   = int(m.group(1))
    age = int(time.time()) - t
    if age < 0 or age > 300:                         # 5-min replay window
        return False
    expected = hmac.new(
        secret.encode(), f'{t}.{body}'.encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, m.group(2))  # constant-time compare


if __name__ == '__main__':
    app.run(port=4000)

Frameworks: in Django, use request.body for the raw bytes and request.headers['SknPay-Signature']. In FastAPI, declare your handler async and read with await request.body().

Pick a language above, configure the webhook endpoint (and grab the whsec_) under Settings → Webhooksin the SKN Pay dashboard, and you have the whole loop.

Getting started

  1. Ask your SKN Pay merchant admin to enable Payments API access on your account.
  2. Generate a key pair under Settings → API keys. The full key is shown once; copy it into your secret manager immediately.
  3. Use a sk_test_… key against the test gateway while you build, then swap to a sk_live_… key when you go live.

Base URL

https://app.sknpay.com/api/v1

Authentication

Every request must carry a Bearer token. Test keys see only test resources, live keys only live resources — they’re fully partitioned.

curl https://app.sknpay.com/api/v1/payments \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx"

Keys can be revoked at any time. Revoked keys reject within a few seconds with HTTP 401.

Live mode vs test mode

Payments

Create a payment

Creates a hosted payment link. Returns a url you redirect or email the customer to — they pay there, then (optionally) come back to a success_url you specify.

POST /api/v1/payments

{
  "amount": 12500,
  "currency": "USD",
  "description": "Order #5821",
  "customer_email": "alice@example.com",
  "customer_name": "Alice Brown",
  "customer_phone": "+18694619400",
  "customer_address": {
    "line1": "123 Main St",
    "line2": "Apt 4B",
    "city": "Basseterre",
    "state": "ZZ",
    "postal_code": "ZZZ",
    "country": "KN"
  },
  "billing_address_collection": "required",
  "phone_number_collection": "auto",
  "reference": "order-5821",
  "success_url": "https://yoursite.com/orders/5821/thanks",
  "cancel_url": "https://yoursite.com/orders/5821",
  "success_message": "Thanks! Your order ships in 1-2 business days.",
  "expires_at": "2026-05-15T18:00:00Z",
  "metadata": { "order_id": "5821", "fulfilment": "express" }
}

Request fields

Every field is optional except where noted. Type and behaviour are summarised below; cross-reference the JSON example above.

FieldTypeEffect
amount
required for fixed-amount
integer (cents)Charge amount in minor units. 1999 = $19.99. Range 1 to 99,999,999. Omit for an “open amount” link where the customer chooses.
currency3-letter ISOUSD or XCD. Must be in the merchant’s enabled currencies. Defaults to merchant’s preferred currency.
descriptionstring ≤500Shown to the customer beneath the amount on the hosted page. Also stored on the receipt + emitted in webhooks.
customer_emailemail ≤254Pre-fills the receipt-email field. Customer can edit. Webhook’s customer_email reflects what they actually submitted.
customer_namestring ≤200Pre-fills the cardholder-name field. Same edit + webhook semantics as customer_email.
customer_phonestring ≤40Pre-fills the phone field on the hosted page. Field is rendered when the merchant has phone collection on or this link sets phone_number_collection: "required".
customer_addressobjectPre-fills the billing-address fields. Object shape: { line1, line2, city, state, postal_code, country } — country is ISO-2 (e.g. "KN", "US"). Useful for AVS on cross-border cards. Customer can edit; we send what they typed to the gateway.
billing_address_collectionauto / required / neverOverride the merchant’s default for showing address fields on this specific session. auto (default, or omitted) = inherit merchant setting. required = show fields even if merchant default is off (useful for fraud-sensitive sessions). never = hide even if merchant default is on (low-friction sessions).
phone_number_collectionauto / required / neverSame shape as billing_address_collection but for the phone field. Per-session override of the merchant’s phone-collection default.
referencestring ≤200Your order / invoice / transaction id. Single string, searchable from the merchant dashboard transaction list. Echoed back in API responses + every webhook for the payment’s lifetime. Equivalent to Stripe Checkout’s client_reference_id. Use metadata for arbitrary key/value data beyond a single reference.
success_urlhttps:// URLAuto-redirect destination after the success-screen 5s countdown. We auto-append ?payment_id=<id>. Details below.
cancel_urlhttps:// URLCurrently informational only — stored + emitted in webhooks. Future-proofing for a “Cancel” button.
success_messagestring ≤500Custom thank-you copy on the success screen (e.g. “Order #5821 confirmed — your delivery code is ABC123”). Falls back to a default when omitted.
expires_atISO 8601Link refuses payment after this. Must be in the future, capped at 30d ahead. Defaults to 24h when omitted.
metadataJSON objectArbitrary key/value pairs. Max 4KB serialised. Echoed back in API responses and on every webhook for the payment’s lifetime. Use it to stash your own foreign keys (order_id, etc).

Not currently exposed via API (set on the merchant dashboard or by their account admin): accepted card brands, brand colours / logo on the hosted page, 3DS toggle, multi-use vs single-use link type. If you need these as API fields, ask support@sknpay.com.

success_url / cancel_url behaviour

Both are optional. After a successful payment, the hosted page shows the merchant’s success message + reference number for ~5 seconds, then auto-redirects the customer tosuccess_url. A “Continue now” link is rendered alongside the countdown so impatient customers can skip the wait. If you omit success_url, the success screen stays put — useful when you don’t need the customer back on your site.

We auto-append ?payment_id=<id>to success_url on redirect(mirroring Stripe’s ?session_id=… convention) so your success page can look up the payment via GET /api/v1/payments/<id> to confirm status. If your success_url already contains a payment_idquery param, your value wins — we don’t overwrite. Other existing query params are preserved.

Example: passing success_url="https://yoursite.com/orders/5821/thanks"lands the customer at https://yoursite.com/orders/5821/thanks?payment_id=plink_cm12ab9c1d3e4f5g6h7i8j9k0. On that page you call our API with the id to confirm:

// success-page handler
const paymentId = new URL(window.location).searchParams.get('payment_id')
const r = await fetch(`https://app.sknpay.com/api/v1/payments/${paymentId}`, {
  headers: { Authorization: `Bearer ${process.env.SKNPAY_API_KEY}` },
})
const payment = await r.json()
if (payment.status === 'succeeded') { /* mark order paid, etc. */ }

(You can also just wait for the payment.succeededwebhook — that’s the recommended source of truth. The?payment_idon the redirect is for the customer- facing page that needs to render “Order #X confirmed” before the webhook arrives.)

cancel_urlis currently informational only — stored on the payment object and surfaced in webhooks, but not rendered as a button on the hosted page (cancellation today happens by closing the tab). Treat it as future-proofing for a “Cancel and return” button we’ll add alongside richer abandon flows.

Both URLs must be https:// and on a host you control. http://, javascript:, and self-redirects to our own domains are rejected at create.

Idempotency

Pass Idempotency-Key: <your-uuid> on any POST that creates state. Repeats within 24 hours return the original response unchanged. Concurrent twins are race-safe.

Response

{
  "object": "payment",
  "id": "plink_cm12ab9c1d3e4f5g6h7i8j9k0",
  "livemode": true,
  "url": "https://pay.sknpay.com/checkout/AbCdEf12",
  "amount": 12500,
  "currency": "USD",
  "status": "open",
  "description": "Order #5821",
  "customer_email": "alice@example.com",
  "customer_name": "Alice Brown",
  "success_url": "https://yoursite.com/orders/5821/thanks",
  "cancel_url": "https://yoursite.com/orders/5821",
  "metadata": { "order_id": "5821" },
  "expires_at": null,
  "created_at": "2026-05-08T18:42:11.000Z"
}

Retrieve a payment

GET /api/v1/payments/{id}

Returns the same shape as Create, with current status:

Cancel a payment

POST /api/v1/payments/{id}/cancel

Idempotent. No-op if the payment is already terminal. Triggers a payment.canceled webhook on the first ACTIVE → CANCELLED transition.

Refunds

Refund a succeeded payment in whole or in part. Multiple partial refunds are allowed up to the payment total.

POST /api/v1/refunds

{
  "payment_id": "plink_cm12ab9c1d3e4f5g6h7i8j9k0",
  "amount": 5000   // optional; omit for full refund
}

Triggers a payment.refunded webhook on success.

Webhooks

Configure webhook endpoints under Settings → Webhooks. Each endpoint gets a unique whsec_… signing secret, shown once at creation time.

Webhook events fire for activity created via this API only — payments created in the merchant's dashboard (payment links, invoices) are notified through the dashboard's own UI and email rather than firing on this channel. This keeps the wire shape consistent: every webhook payload describes a Payment created by POST /payments.

Event types

Event envelope

{
  "id": "evt_e6b0b3c6ff198f6dfc05bb80",
  "object": "event",
  "type": "payment.succeeded",
  "created": 1778276991,
  "livemode": true,
  "data": {
    "object": { /* the payment, same shape as GET /payments/:id */ }
  }
}

Signature verification

Every delivery includes a SknPay-Signature header using the same scheme as Stripe — copy-paste your existing verifier and swap the secret.

SknPay-Signature: t=1778276991,v1=1aae6320bdd8db20d47b637c18b4eb622a4b52f4c672c8010331a9feaf8078f3
SknPay-Event-Id:   evt_e6b0b3c6ff198f6dfc05bb80
SknPay-Event-Type: payment.succeeded

To verify:

  1. Parse t and v1 from the header.
  2. Reject if t is more than 5 minutes old or in the future (replay protection).
  3. Compute HMAC_SHA256(endpoint_secret, t + "." + raw_body).
  4. Compare to v1 with a constant-time comparison.
// Node.js example
import { createHmac, timingSafeEqual } from 'crypto'

function verifySig(rawBody, header, secret) {
  const m = header.match(/^t=(\d+),v1=([0-9a-f]{64})$/)
  if (!m) return false
  const t = parseInt(m[1], 10)
  const ageSec = Math.floor(Date.now() / 1000) - t
  if (ageSec < 0 || ageSec > 300) return false
  const expected = createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex')
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(m[2], 'hex'))
}

Delivery + retries

Testing your integration

Build with a sk_test_…key first. Test-mode charges route through the platform’s shared NMI test gateway — no real money moves, real cards are rejected, and test resources never appear on production reports.

Before writing code, run through every scenario in the playground at sknpay-test-integrator.fly.dev. You’ll see exactly what each API call looks like (request body, response shape) and the signed webhook envelope you’ll need to verify on your end — without writing a single line until you understand the wire format. The playground is open and shareable; send the URL to anyone evaluating SKN Pay.

Test cards

All test cards take any future expiry (e.g. 12/30) and any 3-digit CVV.

Card numberScenarioExpected outcome
4111 1111 1111 1111Standard Visa approvalpayment.succeeded — no 3DS challenge
4000 0000 0000 27013DS frictionlesspayment.succeeded — auth happens silently in the background
4000 0000 0000 24203DS challengeCustomer prompted for OTP. May not fully complete in test mode (sandbox limitation — covered live in production)
4000 0000 0000 26443DS challenge (alt)Same as 2420 — second flavour for retesting

For the canonical NMI list (declines, AVS-fail, CVV-fail variants): NMI test cards reference.

End-to-end checklist

Run through this once before shipping:

  1. Set up. In the SKN Pay dashboard: Settings → Payments → flip Test mode ON; Settings → API keys → create a sk_test_key; Settings → Webhooks → add an endpoint pointing at webhook.site (claim a unique URL there first), copy the whsec_.
  2. Happy path. POST a payment, open the returned url, pay with 4111…. Verify the auto-redirect to success_urlfires after ~5s, and that payment.succeeded lands on webhook.site with a valid signature.
  3. Decline path.Create another payment, pay with a card that triggers a decline (use any non-test card number — they’ll fail in test mode). Verify payment.failed fires.
  4. Cancel. Create a payment, then POST /api/v1/payments/{id}/cancel before the customer pays. Verify the link no longer accepts payments and payment.canceled fires.
  5. Refund. Take a successful payment, then POST /api/v1/refunds { payment_id }. Verify payment.refunded fires; try a partial refund too (amount: 500 on a payment of 1000 minor units) and confirm a second refund of the remainder still works.
  6. Idempotency. POST the same payment twice with the same Idempotency-Key. You should get the same id back — not two payments.
  7. Mode strictness. Try fetching a test payment idwith a sk_live_ key. You should get HTTP 404, not 200.
  8. Signature tampering. Take a real webhook body from webhook.site, mutate one byte, and run it through your verifier. It must reject.
  9. Replay. In Settings → Webhooks → expand your endpoint, click Replay on a delivered row. Verify a second delivery lands on webhook.site with the same SknPay-Event-Id — your code should de-dupe on it.

Quick curl smoke test

Once your sk_test_ key is in your shell, you can exercise the API surface without writing any code:

# Create
curl -X POST https://app.sknpay.com/api/v1/payments \
  -H "Authorization: Bearer $SKNPAY_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: smoketest-$(date +%s)" \
  -d '{
    "amount": 1000,
    "currency": "USD",
    "description": "Smoke test",
    "customer_email": "test@example.com",
    "success_url": "https://example.com/thanks",
    "cancel_url":  "https://example.com/cart"
  }'

# Retrieve (paste the id from the response above)
curl https://app.sknpay.com/api/v1/payments/plink_xxx \
  -H "Authorization: Bearer $SKNPAY_KEY"

# Cancel
curl -X POST https://app.sknpay.com/api/v1/payments/plink_xxx/cancel \
  -H "Authorization: Bearer $SKNPAY_KEY"

# Refund (after the payment has actually been paid)
curl -X POST https://app.sknpay.com/api/v1/refunds \
  -H "Authorization: Bearer $SKNPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "payment_id": "plink_xxx", "amount": 500 }'

Receiving test webhooks

The simplest receiver for early testing is webhook.site: claim a unique URL, paste it into Settings → Webhooks as a new endpoint, and every delivery shows up on the page in real time with full headers and body. For local development pair it with ngrok so your localhost handler receives the same signed body.

Errors

Errors are returned as JSON with a non-2xx status:

{
  "error": "Amount must be at least 100 minor units."
}

Rate limits

Per-key default 100 requests/minute. Burst above this returns 429. Contact support if you need a higher quota.

Support

Email support@sknpay.com for integration questions. Include your test-mode key prefix and a recent event_id when reporting webhook issues.