# SKN Pay — Payments API Reference

**Version:** v1
**Updated:** 2026-05-09
**Wire format:** Stripe-flavoured (Bearer auth, JSON in/out, HMAC-signed webhooks)
**HTML version:** <https://app.sknpay.com/docs/payments>
**Live playground:** <https://sknpay-test-integrator.fly.dev> — try the API in
your browser without writing code; covers 7 scenarios (standard / 3DS / decline
/ cancel / idempotency / refund) with full request, response, and signed-webhook
visibility.

---

## Reading this with an AI assistant

This file is the canonical machine-readable reference. Drop it into your project's
`docs/` directory and add a pointer to it from your `AGENTS.md` / `CLAUDE.md` /
`.cursor/rules/` so your assistant has full API context when generating
integration code:

```markdown
<!-- in your AGENTS.md or CLAUDE.md -->
## SKN Pay integration
The SKN Pay Payments API reference lives at `docs/sknpay-api.md`.
Read it before generating any code that calls `app.sknpay.com/api/v1`
or implements webhook receivers.
```

---

## Overview

The Payments API lets your application:

- Create hosted payment links (customers pay on a SKN Pay-hosted page; cards never touch your server — PCI SAQ A scope).
- Retrieve payment state.
- Cancel unpaid payments.
- Issue full or partial refunds.
- Receive signed webhook events when a payment changes status.

Cards are tokenised inside Collect.js iframes on the SKN Pay hosted page;
your server only ever sees `payment_id` references.

---

## Quickstart

End-to-end worked examples in three languages. Pick your stack and copy.

### Node.js

```javascript
// server.js — Node 20+, no dependencies beyond express
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.
    // IMPORTANT: same event id may arrive more than once — dedupe on event.id
  }
  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)
```

### PHP

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

```php
<?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
}
```

**Framework adapters:**
- **Laravel** — replace `$_SERVER[...]` with `$request->header('SknPay-Signature')` and `file_get_contents('php://input')` with `$request->getContent()`.
- **WordPress** — register 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()`).

```python
# 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)
```

**Framework adapters:**
- **Django** — read raw bytes from `request.body`; access headers via `request.headers['SknPay-Signature']`.
- **FastAPI** — declare the handler `async` and read with `await request.body()`.

---

## 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 `sk_live_…` 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.

```bash
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 the merchant's real NMI gateway. Real cards, real money.
- **Test keys** route through the platform's shared NMI test gateway. Use the test cards listed under [Testing](#testing-your-integration).
- 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

`POST /api/v1/payments`

Creates a hosted payment link. Returns a `url` you redirect the customer to —
they pay there, then auto-redirect to your `success_url` after ~5 seconds.

**Request body:**

```json
{
  "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" }
}
```

Every field is optional except where noted.

| Field | Type | Effect |
|---|---|---|
| `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. |
| `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 billing-address fields. 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. `never` = hide even if merchant default is on. |
| `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 under "success_url / cancel_url behaviour". |
| `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. Email
<support@sknpay.com> if you need these as API fields.

### 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 to skip the wait.

**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=plink_cm12ab9c1d3e4f5g6h7i8j9k0`.
On that page you call our API with the id to confirm:

```javascript
// 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 (network race).

`cancel_url` is currently informational only — stored on the payment
and emitted in webhooks, but not rendered as a button on the hosted
page (cancellation today happens by closing the tab). 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.

```bash
curl -X POST https://app.sknpay.com/api/v1/payments \
  -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: order-5821-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{"amount":12500,"currency":"USD",...}'
```

### Response

```json
{
  "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",
  "success_message": "Thanks! Your order ships in 1-2 business days.",
  "metadata": { "order_id": "5821" },
  "expires_at": "2026-05-15T18:00:00Z",
  "created_at": "2026-05-08T18:42:11.000Z"
}
```

**Status values:**

| Status | Meaning |
|---|---|
| `open` | Link is active, customer hasn't paid yet. |
| `succeeded` | Payment captured. |
| `canceled` | Link was cancelled before payment. |
| `expired` | Link passed its `expires_at`. |

### Retrieve a payment

`GET /api/v1/payments/{id}`

Returns the same shape as Create, with current `status`. Mode-strict — a
`sk_test_` key gets 404 for a livemode resource, and vice versa.

### 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

`POST /api/v1/refunds`

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

```json
{
  "payment_id": "plink_cm12ab9c1d3e4f5g6h7i8j9k0",
  "amount": 5000
}
```

`amount` is 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 | When it fires |
|---|---|
| `payment.succeeded` | Payment captured. |
| `payment.failed` | Last attempt declined. |
| `payment.canceled` | Payment cancelled before capture. |
| `payment.refunded` | Full or partial refund posted. |

### Event envelope

```json
{
  "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.

```javascript
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 `eventId` may arrive more than once — **de-duplicate on your side** keyed on `event.id`.

### Defensive integration

Webhooks are advisory, **not authoritative**. Treat the API as the source of truth:

1. When the customer lands on your `success_url`, optionally `GET /api/v1/payments/{id}` to confirm status — gives your app both pull-based + push-based confirmation.
2. Make your webhook handler **idempotent on `event.id`** — replays and network retries are normal.
3. Don't depend on the success-URL redirect either — the customer might close the tab. Authoritative state is "I called the API and got `status: succeeded`" or "I received a signed `payment.succeeded` webhook."

---

## 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
<https://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 — 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 |
| `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 declines, AVS-fail, CVV-fail variants: <https://support.nmi.com/hc/en-gb/articles/115002375583-Test-Cards>

### End-to-end checklist

Run through this once before shipping:

1. **Set up.** Settings → Payments → flip Test mode ON; Settings → API keys → create a `sk_test_` key; Settings → Webhooks → add an endpoint pointing at <https://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_url` fires after ~5s, and that `payment.succeeded` lands on webhook.site with a valid signature.
3. **Decline path.** Create another payment, pay with any non-test card. 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 (`amount: 500` on a payment of 1000) 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.
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 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:

```bash
# 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

Simplest receiver for early testing: <https://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](https://ngrok.com) so your localhost handler receives the
same signed body.

---

## Errors

Errors return as JSON with a non-2xx status:

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

| Status | Meaning |
|---|---|
| `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 (e.g. `sk_test_3xK9p…`) and a recent `event_id` when reporting
webhook issues so we can correlate against delivery logs.

---

*This file is the canonical machine-readable reference. The browsable HTML
version is at <https://app.sknpay.com/docs/payments>. Both are published from
the same source on every release of the SKN Pay platform.*
