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

  1. Ask SKN Pay (support@sknpay.com) to switch on the Payments API for the merchant account you’re integrating.
  2. 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.
  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:// URLWhere 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_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. 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.
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 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:

Cancel a payment

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

Cancels a payment that hasn’t been paid, so its link stops accepting payment. Returns the payment with status: "canceled".

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"
}
FieldTypeEffect
payment_id
required
stringThe payment’s id.
amountinteger (cents)How much to refund. Omit to refund everything still unrefunded.
reason_categorystringOne of customer_requested, duplicate_charge, fraud, service_not_delivered, goodwill, other. Defaults to other.
reasonstring ≤500Free-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

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

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:

  1. 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 the whsec_. 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.
  2. Happy path. POST a payment, open the returned url, pay with 4111…. Verify the auto-redirect to success_url fires 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 id with 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 (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.

Statuserror.typeMeaning
400invalid_request, invalid_body, invalid_idempotency_key, invalid_expires_at, currency_not_enabled, invalid_success_url, invalid_cancel_url, metadata_too_large, amount_exceeds_balanceSomething in the request needs fixing.
401authentication_required, invalid_api_key, revoked_api_keyMissing, unrecognised or revoked API key.
403payments_api_disabled, merchant_inactiveThe key is valid, but the merchant can’t use the API right now.
404not_foundNo such payment, or it belongs to the other mode (a test payment fetched with a live key).
409invalid_state, no_refundable_charge, fully_refundedThe payment’s state doesn’t allow it: cancelling a paid payment, or refunding one that was never paid or is fully refunded.
429rate_limitedOver the rate limit. Retry with backoff.
502gateway_refund_failedThe gateway refused the refund; nothing was refunded.
5xxinternalServer 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:

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.