SKN Pay — Payments API
v1 · Last updated 2026-10-28 · Stripe-flavoured wire format
Try the API in your browser
sknpay-test-integrator.fly.dev — interactive playground covering the standard, decline, cancel, idempotency and refund flows. 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 → Webhooks in 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 → Webhooks in the SKN Pay dashboard, and you have the whole loop.
Getting started
- Ask SKN Pay (support@sknpay.com) to switch on the Payments API for the merchant account you’re integrating.
- Create a key under Settings → API keys (the merchant’s owner or an admin can do this). 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 | Where the customer goes back to if the payment can’t be completed. If they open the payment page after it has expired or been cancelled, the page shows a “Return to your business” button that links here. Also returned by the API and in webhooks. Details below. |
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. Send UTC ending in Z (2026-10-21T03:59:59Z) or a time with an offset (2026-10-20T23:59:59-04:00); a time with no timezone is rejected. Must be in the future. Dates more than 30 days ahead are shortened to 30 days. Defaults to 24 hours 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 to success_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_id query 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=cmvq8x2ab0001sb01k3n7d9fe. 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.succeeded webhook — that’s the recommended source of truth. The ?payment_id on the redirect is for the customer- facing page that needs to render “Order #X confirmed” before the webhook arrives.)
cancel_url is where the customer goes back to when a payment can’t be completed. If they open the payment page after it has expired or been cancelled (for example, after you cancelled it through the API), the page shows a “Return to your business” button that links to cancel_url. A paid payment doesn’t show it, because the customer was already sent to success_url. There’s no Cancel button on the payment page itself yet: a customer who changes their mind closes the tab. cancel_url is also returned by the API and in webhooks.
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: <key> on any POST that creates state. A key is 1–255 characters of letters, digits, -, _ and ..
A repeat within 24 hours returns the original response unchanged. The request body isn’t compared, so use a new key whenever you mean a new payment (for example order-5821-1, then order-5821-2 if you re-issue it). Concurrent twins are race-safe.
Response
201 Created, with the payment:
{
"object": "payment",
"id": "cmvq8x2ab0001sb01k3n7d9fe",
"amount": 12500,
"currency": "USD",
"status": "requires_payment",
"livemode": true,
"url": "https://pay.sknpay.com/checkout/AbCdEf12",
"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.",
"metadata": { "order_id": "5821", "fulfilment": "express" },
"expires_at": "2026-05-15T18:00:00.000Z",
"created_at": "2026-05-08T18:42:11.000Z"
}id is the payment’s ID; use it with every other endpoint. expires_at and created_at always come back in UTC.
Retrieve a payment
GET /api/v1/payments/{id}Returns the same shape as Create, with its current status:
requires_payment— not paid yet. The customer can pay untilexpires_at. A declined attempt leaves the payment here.succeeded— paid.canceled— cancelled through the API before it was paid.expired— reachedexpires_atwithout being paid.
Cancel a payment
POST /api/v1/payments/{id}/cancelCancels a payment that hasn’t been paid, so its link stops accepting payment. Returns the payment with status: "canceled".
- Cancelling a payment that’s already cancelled returns it unchanged.
- Cancelling a paid payment returns
409withtype: "invalid_state"; refund it instead. payment.canceledfires once, when the payment first becomes cancelled.
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": "cmvq8x2ab0001sb01k3n7d9fe",
"amount": 5000,
"reason_category": "customer_requested",
"reason": "Customer changed their mind"
}| Field | Type | Effect |
|---|---|---|
payment_idrequired | string | The payment’s id. |
amount | integer (cents) | How much to refund. Omit to refund everything still unrefunded. |
reason_category | string | One of customer_requested, duplicate_charge, fraud, service_not_delivered, goodwill, other. Defaults to other. |
reason | string ≤500 | Free-text note, shown to the merchant. |
Returns the refund and fires a payment.refunded webhook:
{
"object": "refund",
"id": "cmvq9b7cd0003sb01p2r8f4gh",
"payment_id": "cmvq8x2ab0001sb01k3n7d9fe",
"amount": 5000,
"currency": "USD",
"status": "succeeded",
"livemode": true,
"reason_category": "customer_requested",
"reason": "Customer changed their mind",
"created_at": "2026-05-09T10:15:42.000Z"
}A refund larger than what’s left returns 400 amount_exceeds_balance. A payment that was never paid, or is already fully refunded, returns 409 no_refundable_charge. If the gateway refuses the refund you get 502 gateway_refund_failed and nothing is refunded; the message says why.
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— a payment attempt was declined. Fires on every declined attempt; the payment staysrequires_payment, so the customer can try again.payment.canceled— payment cancelled through the API before it was paid.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 |
For the canonical NMI list (declines, AVS-fail, CVV-fail variants): NMI test cards reference.
3-D Secure (the bank’s verification screen) doesn’t run in test mode, so there’s no way to try it with a test key. Live payments get it when the merchant has 3-D Secure switched on; your integration doesn’t change either way.
End-to-end checklist
Run through this once before shipping:
- Set up. In the SKN Pay dashboard: 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_. Test payments show on the Transactions page under Test payments. Don’t switch the merchant’s account into test mode to see them: while it’s on, the merchant’s own invoices are charged through the test gateway too. - 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 (set PAYMENT_ID to the "id" from the response above)
PAYMENT_ID=cmvq8x2ab0001sb01k3n7d9fe
curl https://app.sknpay.com/api/v1/payments/$PAYMENT_ID \
-H "Authorization: Bearer $SKNPAY_KEY"
# Cancel
curl -X POST https://app.sknpay.com/api/v1/payments/$PAYMENT_ID/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\": \"$PAYMENT_ID\", \"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": {
"type": "invalid_request",
"message": "expires_at: must be an ISO 8601 date-time with a timezone, e.g. 2026-10-21T03:59:59Z or 2026-10-20T23:59:59-04:00"
}
}Branch on error.type; error.message is written for people and may change. Validation messages start with the name of the field at fault.
| Status | error.type | Meaning |
|---|---|---|
| 400 | invalid_request, invalid_body, invalid_idempotency_key, invalid_expires_at, currency_not_enabled, invalid_success_url, invalid_cancel_url, metadata_too_large, amount_exceeds_balance | Something in the request needs fixing. |
| 401 | authentication_required, invalid_api_key, revoked_api_key | Missing, unrecognised or revoked API key. |
| 403 | payments_api_disabled, merchant_inactive | The key is valid, but the merchant can’t use the API right now. |
| 404 | not_found | No such payment, or it belongs to the other mode (a test payment fetched with a live key). |
| 409 | invalid_state, no_refundable_charge, fully_refunded | The payment’s state doesn’t allow it: cancelling a paid payment, or refunding one that was never paid or is fully refunded. |
| 429 | rate_limited | Over the rate limit. Retry with backoff. |
| 502 | gateway_refund_failed | The gateway refused the refund; nothing was refunded. |
| 5xx | internal | Server error. Retry with backoff. |
Rate limits
120 requests/minute per source IP, across all /api/v1 endpoints. Burst above this returns 429 with type: "rate_limited"; retry with backoff. The limit is applied before authentication, so a malformed or wrong key counts against it too.
Contact support if you need a higher quota. A per-key quota is planned; today the limit is per-IP, which matters if several of your services share an egress address.
How often to poll
Use the webhook as your trigger and GET /api/v1/payments/{id} as your confirmation. That combination needs roughly one read per order, and it is what we recommend.
If your architecture can’t receive webhooks and you must poll, poll slowly and back off:
- Budget against the aggregate limit, not the per-order interval. The 120/min cap is per source IP across all your pending orders, so what matters is
pending_orders ÷ interval. Thirty pending orders polled every 10 seconds is 180 req/min — over the limit, and the 429s will look unrelated to any single order. - Start at 10 seconds, backing off toward 60 seconds for older sessions.
- Stop at a terminal state (
succeeded,canceled,expired) — those never change afterwards. - Stop at
expires_at. An expired session will not become paid. - Never poll in a tight loop. A 1–2 second interval per pending order buys you nothing — the customer is typing card details, and 3-D Secure alone takes tens of seconds.
A session sitting in requires_payment overnight is an abandoned checkout, not a pending one. Let it expire.
Support
Email support@sknpay.com for integration questions. Include your test-mode key prefix and a recent event_id when reporting webhook issues.