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
- Ask your SKN Pay merchant admin to enable Payments API access on your account.
- Generate a key pair under Settings → API keys. The full key is shown once; copy it into your secret manager immediately.
- Use a
sk_test_…key against the test gateway while you build, then swap to ask_live_…key when you go live.
Base URL
https://app.sknpay.com/api/v1Authentication
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
- Live keys route through your real NMI gateway. Real cards, real money.
- Test keys route through the platform’s shared NMI test gateway. Use
4111 1111 1111 1111, any future expiry, any CVV. - Each resource is locked to its mode at creation. A test-mode payment_id is invisible to live keys, and vice versa.
- Test resources never appear on production reports, exports, or revenue tiles.
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.
| Field | Type | Effect |
|---|---|---|
amountrequired 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. |
currency | 3-letter ISO | USD or XCD. Must be in the merchant’s enabled currencies. Defaults to merchant’s preferred currency. |
description | string ≤500 | Shown to the customer beneath the amount on the hosted page. Also stored on the receipt + emitted in webhooks. |
customer_email | email ≤254 | Pre-fills the receipt-email field. Customer can edit. Webhook’s customer_email reflects what they actually submitted. |
customer_name | string ≤200 | Pre-fills the cardholder-name field. Same edit + webhook semantics as customer_email. |
customer_phone | string ≤40 | Pre-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_address | object | Pre-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_collection | auto / required / never | Override 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_collection | auto / required / never | Same shape as billing_address_collection but for the phone field. Per-session override of the merchant’s phone-collection default. |
reference | string ≤200 | Your 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_url | https:// URL | Auto-redirect destination after the success-screen 5s countdown. We auto-append ?payment_id=<id>. Details below. |
cancel_url | https:// URL | Currently informational only — stored + emitted in webhooks. Future-proofing for a “Cancel” button. |
success_message | string ≤500 | Custom 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_at | ISO 8601 | Link refuses payment after this. Must be in the future, capped at 30d ahead. Defaults to 24h when omitted. |
metadata | JSON object | Arbitrary 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:
open— link is active, customer hasn’t paid yet.succeeded— payment captured.canceled— link was cancelled before payment.expired— link passed itsexpires_at.
Cancel a payment
POST /api/v1/payments/{id}/cancelIdempotent. 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
payment.succeeded— payment captured.payment.failed— last attempt declined.payment.canceled— payment cancelled.payment.refunded— full or partial refund posted.
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.succeededTo verify:
- Parse
tandv1from the header. - Reject if
tis more than 5 minutes old or in the future (replay protection). - Compute
HMAC_SHA256(endpoint_secret, t + "." + raw_body). - Compare to
v1with 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
- Your endpoint must respond 2xx within 10 seconds.
- Non-2xx or timeout: we retry at 1m, 5m, 30m, 2h, 6h, then mark the delivery abandoned.
- An endpoint that fails 20 deliveries in a row is auto-disabled.
- Replay any abandoned delivery from the dashboard’s deliveries log.
- Same
eventIdmay arrive more than once — de-duplicate on your side.
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 number | Scenario | Expected outcome |
|---|---|---|
4111 1111 1111 1111 | Standard Visa approval | payment.succeeded — no 3DS challenge |
4000 0000 0000 2701 | 3DS frictionless | payment.succeeded — auth happens silently in the background |
4000 0000 0000 2420 | 3DS challenge | Customer prompted for OTP. May not fully complete in test mode (sandbox limitation — covered live in production) |
4000 0000 0000 2644 | 3DS 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:
- 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 thewhsec_. - Happy path. POST a payment, open the returned
url, pay with4111…. Verify the auto-redirect tosuccess_urlfires after ~5s, and thatpayment.succeededlands on webhook.site with a valid signature. - 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.failedfires. - Cancel. Create a payment, then
POST /api/v1/payments/{id}/cancelbefore the customer pays. Verify the link no longer accepts payments andpayment.canceledfires. - Refund. Take a successful payment, then
POST /api/v1/refunds { payment_id }. Verifypayment.refundedfires; try a partial refund too (amount: 500on a payment of 1000 minor units) and confirm a second refund of the remainder still works. - Idempotency. POST the same payment twice with the same
Idempotency-Key. You should get the sameidback — not two payments. - Mode strictness. Try fetching a test payment
idwith ask_live_key. You should get HTTP 404, not 200. - Signature tampering. Take a real webhook body from webhook.site, mutate one byte, and run it through your verifier. It must reject.
- 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."
}- 400 — validation error (bad input).
- 401 — missing or invalid API key.
- 403 — Payments API not enabled on this merchant.
- 404 — resource not found, or wrong-mode key.
- 409 — idempotency-key conflict (mismatched body).
- 429 — rate-limit exceeded.
- 5xx — server error. Retry with backoff.
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.